feat: 初始化项目基础架构与核心功能模块
This commit is contained in:
commit
6a792e167c
92 files changed
+29329
No files matched your search
@@ -0,0 +1,45 @@
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install
|
||||
- run: pnpm test
|
||||
|
||||
build:
|
||||
needs: test
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows-latest, macos-latest, ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install
|
||||
- name: Build & Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: pnpm dist
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.lagzero-dev
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0] - 2026-02-09
|
||||
### Added
|
||||
- Initial release of LagZero.
|
||||
- Core: sing-box integration with start/stop/restart support.
|
||||
- UI: Dashboard with real-time latency chart and node management.
|
||||
- UI: Rule editor with Routing and IP Rule modes.
|
||||
- Protocol: Support for VMess, VLESS, Shadowsocks parsing.
|
||||
- CI: GitHub Actions for cross-platform build and release.
|
||||
@@ -0,0 +1,89 @@
|
||||
# LagZero
|
||||
|
||||
一款基于 Vue3 + TypeScript + UnoCSS + Electron 的跨平台游戏加速器桌面客户端。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **现代化 UI**: 采用 UnoCSS 构建的原子化暗色主题界面。
|
||||
- **核心集成**: 内置 sing-box 核心管理,支持自动下载与版本校验。
|
||||
- **多协议支持**: 支持 VLESS, VMess, Trojan, Shadowsocks 等多种协议导入。
|
||||
- **规则管理**: 提供可视化的路由规则编辑器与 IP 规则管理。
|
||||
- **跨平台**: 支持 Windows, macOS, Linux。
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 环境要求
|
||||
- Node.js 18+
|
||||
- pnpm 9+
|
||||
|
||||
### 安装依赖
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 启动开发环境
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
此命令会同时启动 Vite 开发服务器(渲染进程)和 Electron(主进程)。
|
||||
|
||||
### 构建打包
|
||||
|
||||
#### 1. 打包为安装文件 (Production Installer)
|
||||
构建适用于当前操作系统的安装包(如 Windows 的 `.exe`、macOS 的 `.dmg`、Linux 的 `.AppImage`)。
|
||||
```bash
|
||||
pnpm dist
|
||||
```
|
||||
构建产物位于 `release/` 目录下。
|
||||
|
||||
#### 2. 打包为免安装目录 (Unpacked Directory)
|
||||
仅进行打包但不生成安装程序,输出为可执行文件夹。适合快速测试生产环境构建结果。
|
||||
```bash
|
||||
pnpm pack
|
||||
```
|
||||
|
||||
### 常见问题 (Troubleshooting)
|
||||
|
||||
#### 原生模块 ABI 不匹配 (Native Module Mismatch)
|
||||
本项目使用了 `better-sqlite3` 等原生模块。如果启动时报错 `NODE_MODULE_VERSION` 不匹配(例如 `127` vs `143`),说明原生模块是针对本地 Node.js 编译的,而不是 Electron。
|
||||
|
||||
请执行以下命令针对 Electron 重新编译依赖:
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
$env:npm_config_runtime='electron'; $env:npm_config_target='40.2.1'; $env:npm_config_disturl='https://electronjs.org/headers'; pnpm rebuild better-sqlite3
|
||||
```
|
||||
*(注:`40.2.1` 应替换为你当前安装的 Electron 版本)*
|
||||
|
||||
**macOS / Linux:**
|
||||
```bash
|
||||
npm_config_runtime=electron npm_config_target=40.2.1 npm_config_disturl=https://electronjs.org/headers pnpm rebuild better-sqlite3
|
||||
```
|
||||
|
||||
### 测试
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
##### 项目结构
|
||||
- `src/`: Vue 前端源码
|
||||
- `electron/`: Electron 主进程与预加载脚本
|
||||
- `electron/singbox/`: sing-box 核心管理逻辑
|
||||
- `.github/workflows/`: CI/CD 配置
|
||||
|
||||
## 数据库说明 (Database)
|
||||
|
||||
本项目使用 **SQLite** 存储本地数据。
|
||||
|
||||
### 数据文件路径
|
||||
- **开发环境 (Dev):** 项目根目录下的 `.lagzero-dev/lagzero.db`
|
||||
- **生产环境 (Prod):** 用户数据目录下的 `lagzero.db`
|
||||
- Windows: `%APPDATA%\LagZero\lagzero.db`
|
||||
- macOS: `~/Library/Application Support/LagZero/lagzero.db`
|
||||
- Linux: `~/.config/lagzero/lagzero.db`
|
||||
|
||||
### 管理工具
|
||||
推荐使用 [SQLiteStudio](https://sqlitestudio.pl/) 或其他 SQLite GUI 工具查看和管理数据库文件。
|
||||
|
||||
### 数据初始化
|
||||
应用首次启动时,会自动检查并创建所需的表结构(Games, Nodes, Categories, Profiles),并写入默认的游戏分类数据(如 FPS, MOBA, RPG 等)。
|
||||
@@ -0,0 +1,224 @@
|
||||
body, html {
|
||||
margin:0; padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
font-family: Helvetica Neue, Helvetica, Arial;
|
||||
font-size: 14px;
|
||||
color:#333;
|
||||
}
|
||||
.small { font-size: 12px; }
|
||||
*, *:after, *:before {
|
||||
-webkit-box-sizing:border-box;
|
||||
-moz-box-sizing:border-box;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
h1 { font-size: 20px; margin: 0;}
|
||||
h2 { font-size: 14px; }
|
||||
pre {
|
||||
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-moz-tab-size: 2;
|
||||
-o-tab-size: 2;
|
||||
tab-size: 2;
|
||||
}
|
||||
a { color:#0074D9; text-decoration:none; }
|
||||
a:hover { text-decoration:underline; }
|
||||
.strong { font-weight: bold; }
|
||||
.space-top1 { padding: 10px 0 0 0; }
|
||||
.pad2y { padding: 20px 0; }
|
||||
.pad1y { padding: 10px 0; }
|
||||
.pad2x { padding: 0 20px; }
|
||||
.pad2 { padding: 20px; }
|
||||
.pad1 { padding: 10px; }
|
||||
.space-left2 { padding-left:55px; }
|
||||
.space-right2 { padding-right:20px; }
|
||||
.center { text-align:center; }
|
||||
.clearfix { display:block; }
|
||||
.clearfix:after {
|
||||
content:'';
|
||||
display:block;
|
||||
height:0;
|
||||
clear:both;
|
||||
visibility:hidden;
|
||||
}
|
||||
.fl { float: left; }
|
||||
@media only screen and (max-width:640px) {
|
||||
.col3 { width:100%; max-width:100%; }
|
||||
.hide-mobile { display:none!important; }
|
||||
}
|
||||
|
||||
.quiet {
|
||||
color: #7f7f7f;
|
||||
color: rgba(0,0,0,0.5);
|
||||
}
|
||||
.quiet a { opacity: 0.7; }
|
||||
|
||||
.fraction {
|
||||
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
background: #E8E8E8;
|
||||
padding: 4px 5px;
|
||||
border-radius: 3px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
div.path a:link, div.path a:visited { color: #333; }
|
||||
table.coverage {
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
table.coverage td {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
table.coverage td.line-count {
|
||||
text-align: right;
|
||||
padding: 0 5px 0 20px;
|
||||
}
|
||||
table.coverage td.line-coverage {
|
||||
text-align: right;
|
||||
padding-right: 10px;
|
||||
min-width:20px;
|
||||
}
|
||||
|
||||
table.coverage td span.cline-any {
|
||||
display: inline-block;
|
||||
padding: 0 5px;
|
||||
width: 100%;
|
||||
}
|
||||
.missing-if-branch {
|
||||
display: inline-block;
|
||||
margin-right: 5px;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #333;
|
||||
color: yellow;
|
||||
}
|
||||
|
||||
.skip-if-branch {
|
||||
display: none;
|
||||
margin-right: 10px;
|
||||
position: relative;
|
||||
padding: 0 4px;
|
||||
background: #ccc;
|
||||
color: white;
|
||||
}
|
||||
.missing-if-branch .typ, .skip-if-branch .typ {
|
||||
color: inherit !important;
|
||||
}
|
||||
.coverage-summary {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
.coverage-summary tr { border-bottom: 1px solid #bbb; }
|
||||
.keyline-all { border: 1px solid #ddd; }
|
||||
.coverage-summary td, .coverage-summary th { padding: 10px; }
|
||||
.coverage-summary tbody { border: 1px solid #bbb; }
|
||||
.coverage-summary td { border-right: 1px solid #bbb; }
|
||||
.coverage-summary td:last-child { border-right: none; }
|
||||
.coverage-summary th {
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coverage-summary th.file { border-right: none !important; }
|
||||
.coverage-summary th.pct { }
|
||||
.coverage-summary th.pic,
|
||||
.coverage-summary th.abs,
|
||||
.coverage-summary td.pct,
|
||||
.coverage-summary td.abs { text-align: right; }
|
||||
.coverage-summary td.file { white-space: nowrap; }
|
||||
.coverage-summary td.pic { min-width: 120px !important; }
|
||||
.coverage-summary tfoot td { }
|
||||
|
||||
.coverage-summary .sorter {
|
||||
height: 10px;
|
||||
width: 7px;
|
||||
display: inline-block;
|
||||
margin-left: 0.5em;
|
||||
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
|
||||
}
|
||||
.coverage-summary .sorted .sorter {
|
||||
background-position: 0 -20px;
|
||||
}
|
||||
.coverage-summary .sorted-desc .sorter {
|
||||
background-position: 0 -10px;
|
||||
}
|
||||
.status-line { height: 10px; }
|
||||
/* yellow */
|
||||
.cbranch-no { background: yellow !important; color: #111; }
|
||||
/* dark red */
|
||||
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
|
||||
.low .chart { border:1px solid #C21F39 }
|
||||
.highlighted,
|
||||
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
|
||||
background: #C21F39 !important;
|
||||
}
|
||||
/* medium red */
|
||||
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
|
||||
/* light red */
|
||||
.low, .cline-no { background:#FCE1E5 }
|
||||
/* light green */
|
||||
.high, .cline-yes { background:rgb(230,245,208) }
|
||||
/* medium green */
|
||||
.cstat-yes { background:rgb(161,215,106) }
|
||||
/* dark green */
|
||||
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
|
||||
.high .chart { border:1px solid rgb(77,146,33) }
|
||||
/* dark yellow (gold) */
|
||||
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
|
||||
.medium .chart { border:1px solid #f9cd0b; }
|
||||
/* light yellow */
|
||||
.medium { background: #fff4c2; }
|
||||
|
||||
.cstat-skip { background: #ddd; color: #111; }
|
||||
.fstat-skip { background: #ddd; color: #111 !important; }
|
||||
.cbranch-skip { background: #ddd !important; color: #111; }
|
||||
|
||||
span.cline-neutral { background: #eaeaea; }
|
||||
|
||||
.coverage-summary td.empty {
|
||||
opacity: .5;
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
line-height: 1;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.cover-fill, .cover-empty {
|
||||
display:inline-block;
|
||||
height: 12px;
|
||||
}
|
||||
.chart {
|
||||
line-height: 0;
|
||||
}
|
||||
.cover-empty {
|
||||
background: white;
|
||||
}
|
||||
.cover-full {
|
||||
border-right: none !important;
|
||||
}
|
||||
pre.prettyprint {
|
||||
border: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.com { color: #999 !important; }
|
||||
.ignore-none { color: #999; font-weight: normal; }
|
||||
|
||||
.wrapper {
|
||||
min-height: 100%;
|
||||
height: auto !important;
|
||||
height: 100%;
|
||||
margin: 0 auto -48px;
|
||||
}
|
||||
.footer, .push {
|
||||
height: 48px;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable */
|
||||
var jumpToCode = (function init() {
|
||||
// Classes of code we would like to highlight in the file view
|
||||
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
|
||||
|
||||
// Elements to highlight in the file listing view
|
||||
var fileListingElements = ['td.pct.low'];
|
||||
|
||||
// We don't want to select elements that are direct descendants of another match
|
||||
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
|
||||
|
||||
// Selector that finds elements on the page to which we can jump
|
||||
var selector =
|
||||
fileListingElements.join(', ') +
|
||||
', ' +
|
||||
notSelector +
|
||||
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
|
||||
|
||||
// The NodeList of matching elements
|
||||
var missingCoverageElements = document.querySelectorAll(selector);
|
||||
|
||||
var currentIndex;
|
||||
|
||||
function toggleClass(index) {
|
||||
missingCoverageElements
|
||||
.item(currentIndex)
|
||||
.classList.remove('highlighted');
|
||||
missingCoverageElements.item(index).classList.add('highlighted');
|
||||
}
|
||||
|
||||
function makeCurrent(index) {
|
||||
toggleClass(index);
|
||||
currentIndex = index;
|
||||
missingCoverageElements.item(index).scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
inline: 'center'
|
||||
});
|
||||
}
|
||||
|
||||
function goToPrevious() {
|
||||
var nextIndex = 0;
|
||||
if (typeof currentIndex !== 'number' || currentIndex === 0) {
|
||||
nextIndex = missingCoverageElements.length - 1;
|
||||
} else if (missingCoverageElements.length > 1) {
|
||||
nextIndex = currentIndex - 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
function goToNext() {
|
||||
var nextIndex = 0;
|
||||
|
||||
if (
|
||||
typeof currentIndex === 'number' &&
|
||||
currentIndex < missingCoverageElements.length - 1
|
||||
) {
|
||||
nextIndex = currentIndex + 1;
|
||||
}
|
||||
|
||||
makeCurrent(nextIndex);
|
||||
}
|
||||
|
||||
return function jump(event) {
|
||||
if (
|
||||
document.getElementById('fileSearch') === document.activeElement &&
|
||||
document.activeElement != null
|
||||
) {
|
||||
// if we're currently focused on the search input, we don't want to navigate
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.which) {
|
||||
case 78: // n
|
||||
case 74: // j
|
||||
goToNext();
|
||||
break;
|
||||
case 66: // b
|
||||
case 75: // k
|
||||
case 80: // p
|
||||
goToPrevious();
|
||||
break;
|
||||
}
|
||||
};
|
||||
})();
|
||||
window.addEventListener('keydown', jumpToCode);
|
||||
@@ -0,0 +1,6 @@
|
||||
{"D:\\code\\ai\\LagZero\\electron\\categories\\manager.ts": {"path":"D:\\code\\ai\\LagZero\\electron\\categories\\manager.ts","statementMap":{"0":{"start":{"line":15,"column":2},"end":{"line":15,"column":null}},"1":{"start":{"line":18,"column":4},"end":{"line":18,"column":null}},"2":{"start":{"line":19,"column":4},"end":{"line":19,"column":null}},"3":{"start":{"line":20,"column":4},"end":{"line":20,"column":null}},"4":{"start":{"line":24,"column":4},"end":{"line":28,"column":null}},"5":{"start":{"line":25,"column":8},"end":{"line":25,"column":null}},"6":{"start":{"line":27,"column":8},"end":{"line":27,"column":null}},"7":{"start":{"line":32,"column":4},"end":{"line":32,"column":null}},"8":{"start":{"line":33,"column":4},"end":{"line":33,"column":null}},"9":{"start":{"line":37,"column":19},"end":{"line":37,"column":null}},"10":{"start":{"line":38,"column":4},"end":{"line":38,"column":null}},"11":{"start":{"line":39,"column":4},"end":{"line":39,"column":null}},"12":{"start":{"line":43,"column":19},"end":{"line":43,"column":null}},"13":{"start":{"line":44,"column":4},"end":{"line":44,"column":null}},"14":{"start":{"line":45,"column":4},"end":{"line":45,"column":null}},"15":{"start":{"line":50,"column":23},"end":{"line":50,"column":null}},"16":{"start":{"line":51,"column":19},"end":{"line":51,"column":null}},"17":{"start":{"line":53,"column":4},"end":{"line":67,"column":null}},"18":{"start":{"line":54,"column":6},"end":{"line":66,"column":null}},"19":{"start":{"line":55,"column":8},"end":{"line":65,"column":null}},"20":{"start":{"line":56,"column":10},"end":{"line":64,"column":null}},"21":{"start":{"line":57,"column":26},"end":{"line":57,"column":null}},"22":{"start":{"line":58,"column":12},"end":{"line":58,"column":null}},"23":{"start":{"line":58,"column":38},"end":{"line":58,"column":null}},"24":{"start":{"line":59,"column":12},"end":{"line":61,"column":null}},"25":{"start":{"line":60,"column":14},"end":{"line":60,"column":null}},"26":{"start":{"line":60,"column":37},"end":{"line":60,"column":null}},"27":{"start":{"line":68,"column":4},"end":{"line":68,"column":null}},"28":{"start":{"line":72,"column":4},"end":{"line":72,"column":null}},"29":{"start":{"line":72,"column":47},"end":{"line":72,"column":60}},"30":{"start":{"line":73,"column":4},"end":{"line":73,"column":null}},"31":{"start":{"line":73,"column":65},"end":{"line":73,"column":84}},"32":{"start":{"line":74,"column":4},"end":{"line":74,"column":null}},"33":{"start":{"line":74,"column":59},"end":{"line":74,"column":74}},"34":{"start":{"line":75,"column":4},"end":{"line":75,"column":null}},"35":{"start":{"line":75,"column":81},"end":{"line":75,"column":116}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":17,"column":2},"end":{"line":17,"column":14}},"loc":{"start":{"line":17,"column":35},"end":{"line":21,"column":null}},"line":17},"1":{"name":"(anonymous_1)","decl":{"start":{"line":23,"column":16},"end":{"line":23,"column":31}},"loc":{"start":{"line":23,"column":31},"end":{"line":29,"column":null}},"line":23},"2":{"name":"(anonymous_2)","decl":{"start":{"line":31,"column":15},"end":{"line":31,"column":24}},"loc":{"start":{"line":31,"column":24},"end":{"line":34,"column":null}},"line":31},"3":{"name":"(anonymous_3)","decl":{"start":{"line":36,"column":15},"end":{"line":36,"column":20}},"loc":{"start":{"line":36,"column":40},"end":{"line":40,"column":null}},"line":36},"4":{"name":"(anonymous_4)","decl":{"start":{"line":42,"column":15},"end":{"line":42,"column":22}},"loc":{"start":{"line":42,"column":34},"end":{"line":46,"column":null}},"line":42},"5":{"name":"(anonymous_5)","decl":{"start":{"line":48,"column":9},"end":{"line":48,"column":23}},"loc":{"start":{"line":48,"column":89},"end":{"line":69,"column":null}},"line":48},"6":{"name":"(anonymous_6)","decl":{"start":{"line":71,"column":10},"end":{"line":71,"column":24}},"loc":{"start":{"line":71,"column":24},"end":{"line":76,"column":null}},"line":71},"7":{"name":"(anonymous_7)","decl":{"start":{"line":72,"column":41},"end":{"line":72,"column":47}},"loc":{"start":{"line":72,"column":47},"end":{"line":72,"column":60}},"line":72},"8":{"name":"(anonymous_8)","decl":{"start":{"line":73,"column":38},"end":{"line":73,"column":39}},"loc":{"start":{"line":73,"column":65},"end":{"line":73,"column":84}},"line":73},"9":{"name":"(anonymous_9)","decl":{"start":{"line":74,"column":40},"end":{"line":74,"column":41}},"loc":{"start":{"line":74,"column":59},"end":{"line":74,"column":74}},"line":74},"10":{"name":"(anonymous_10)","decl":{"start":{"line":75,"column":39},"end":{"line":75,"column":40}},"loc":{"start":{"line":75,"column":81},"end":{"line":75,"column":116}},"line":75}},"branchMap":{"0":{"loc":{"start":{"line":51,"column":19},"end":{"line":51,"column":null}},"type":"cond-expr","locations":[{"start":{"line":51,"column":49},"end":{"line":51,"column":64}},{"start":{"line":51,"column":64},"end":{"line":51,"column":null}}],"line":51},"1":{"loc":{"start":{"line":54,"column":6},"end":{"line":66,"column":Line truncated
|
||||
,"D:\\code\\ai\\LagZero\\electron\\config\\store.ts": {"path":"D:\\code\\ai\\LagZero\\electron\\config\\store.ts","statementMap":{"0":{"start":{"line":18,"column":4},"end":{"line":18,"column":null}},"1":{"start":{"line":19,"column":4},"end":{"line":19,"column":null}},"2":{"start":{"line":20,"column":4},"end":{"line":20,"column":null}},"3":{"start":{"line":21,"column":4},"end":{"line":21,"column":null}},"4":{"start":{"line":22,"column":4},"end":{"line":22,"column":null}},"5":{"start":{"line":26,"column":4},"end":{"line":35,"column":null}},"6":{"start":{"line":27,"column":6},"end":{"line":31,"column":null}},"7":{"start":{"line":28,"column":8},"end":{"line":28,"column":null}},"8":{"start":{"line":30,"column":8},"end":{"line":30,"column":null}},"9":{"start":{"line":33,"column":6},"end":{"line":33,"column":null}},"10":{"start":{"line":39,"column":4},"end":{"line":39,"column":null}},"11":{"start":{"line":43,"column":4},"end":{"line":43,"column":null}},"12":{"start":{"line":44,"column":4},"end":{"line":44,"column":null}},"13":{"start":{"line":48,"column":4},"end":{"line":53,"column":null}},"14":{"start":{"line":49,"column":6},"end":{"line":49,"column":null}},"15":{"start":{"line":50,"column":6},"end":{"line":50,"column":null}},"16":{"start":{"line":52,"column":6},"end":{"line":52,"column":null}},"17":{"start":{"line":57,"column":4},"end":{"line":57,"column":null}},"18":{"start":{"line":57,"column":68},"end":{"line":57,"column":null}},"19":{"start":{"line":59,"column":4},"end":{"line":81,"column":null}},"20":{"start":{"line":60,"column":6},"end":{"line":60,"column":null}},"21":{"start":{"line":61,"column":12},"end":{"line":61,"column":null}},"22":{"start":{"line":62,"column":25},"end":{"line":62,"column":null}},"23":{"start":{"line":64,"column":6},"end":{"line":66,"column":null}},"24":{"start":{"line":65,"column":8},"end":{"line":65,"column":null}},"25":{"start":{"line":69,"column":20},"end":{"line":69,"column":null}},"26":{"start":{"line":70,"column":22},"end":{"line":73,"column":null}},"27":{"start":{"line":71,"column":21},"end":{"line":71,"column":75}},"28":{"start":{"line":72,"column":18},"end":{"line":72,"column":46}},"29":{"start":{"line":73,"column":24},"end":{"line":73,"column":87}},"30":{"start":{"line":75,"column":6},"end":{"line":78,"column":null}},"31":{"start":{"line":76,"column":25},"end":{"line":76,"column":null}},"32":{"start":{"line":77,"column":8},"end":{"line":77,"column":null}},"33":{"start":{"line":77,"column":44},"end":{"line":77,"column":56}},"34":{"start":{"line":80,"column":6},"end":{"line":80,"column":null}}},"fnMap":{"0":{"name":"(anonymous_0)","decl":{"start":{"line":17,"column":2},"end":{"line":17,"column":14}},"loc":{"start":{"line":17,"column":40},"end":{"line":23,"column":null}},"line":17},"1":{"name":"(anonymous_1)","decl":{"start":{"line":25,"column":10},"end":{"line":25,"column":17}},"loc":{"start":{"line":25,"column":17},"end":{"line":36,"column":null}},"line":25},"2":{"name":"(anonymous_2)","decl":{"start":{"line":38,"column":9},"end":{"line":38,"column":18}},"loc":{"start":{"line":38,"column":18},"end":{"line":40,"column":null}},"line":38},"3":{"name":"(anonymous_3)","decl":{"start":{"line":42,"column":9},"end":{"line":42,"column":13}},"loc":{"start":{"line":42,"column":22},"end":{"line":45,"column":null}},"line":42},"4":{"name":"(anonymous_4)","decl":{"start":{"line":47,"column":16},"end":{"line":47,"column":23}},"loc":{"start":{"line":47,"column":23},"end":{"line":54,"column":null}},"line":47},"5":{"name":"(anonymous_5)","decl":{"start":{"line":56,"column":16},"end":{"line":56,"column":25}},"loc":{"start":{"line":56,"column":25},"end":{"line":82,"column":null}},"line":56},"6":{"name":"(anonymous_6)","decl":{"start":{"line":71,"column":16},"end":{"line":71,"column":21}},"loc":{"start":{"line":71,"column":21},"end":{"line":71,"column":75}},"line":71},"7":{"name":"(anonymous_7)","decl":{"start":{"line":72,"column":13},"end":{"line":72,"column":18}},"loc":{"start":{"line":72,"column":18},"end":{"line":72,"column":46}},"line":72},"8":{"name":"(anonymous_8)","decl":{"start":{"line":73,"column":14},"end":{"line":73,"column":15}},"loc":{"start":{"line":73,"column":24},"end":{"line":73,"column":87}},"line":73},"9":{"name":"(anonymous_9)","decl":{"start":{"line":77,"column":39},"end":{"line":77,"column":44}},"loc":{"start":{"line":77,"column":44},"end":{"line":77,"column":56}},"line":77}},"branchMap":{"0":{"loc":{"start":{"line":27,"column":6},"end":{"line":31,"column":null}},"type":"if","locations":[{"start":{"line":27,"column":6},"end":{"line":31,"column":null}},{"start":{"line":29,"column":13},"end":{"line":31,"column":null}}],"line":27},"1":{"loc":{"start":{"line":57,"column":4},"end":{"line":57,"column":null}},"type":"if","locations":[{"start":{"line":57,"column":4},"end":{"line":57,"column":null}},{"start":{},"end":{}}],"line":57},"2":{"loc":{"start":{"line":57,"column":8},"end":{"line":57,"column":68}},"type":"binary-expr","locations":[{"start":{"line":57,"column":8},"end":{"liLine truncated
|
||||
,"D:\\code\\ai\\LagZero\\src\\composables\\useTheme.ts": {"path":"D:\\code\\ai\\LagZero\\src\\composables\\useTheme.ts","statementMap":{"0":{"start":{"line":6,"column":6},"end":{"line":6,"column":null}},"1":{"start":{"line":7,"column":6},"end":{"line":7,"column":null}},"2":{"start":{"line":8,"column":6},"end":{"line":8,"column":null}},"3":{"start":{"line":11,"column":21},"end":{"line":11,"column":null}},"4":{"start":{"line":14,"column":17},"end":{"line":14,"column":null}},"5":{"start":{"line":15,"column":19},"end":{"line":15,"column":null}},"6":{"start":{"line":17,"column":4},"end":{"line":17,"column":null}},"7":{"start":{"line":20,"column":4},"end":{"line":24,"column":null}},"8":{"start":{"line":21,"column":6},"end":{"line":21,"column":null}},"9":{"start":{"line":23,"column":6},"end":{"line":23,"column":null}},"10":{"start":{"line":28,"column":4},"end":{"line":28,"column":null}},"11":{"start":{"line":29,"column":4},"end":{"line":29,"column":null}},"12":{"start":{"line":33,"column":4},"end":{"line":33,"column":null}},"13":{"start":{"line":34,"column":4},"end":{"line":34,"column":null}},"14":{"start":{"line":35,"column":4},"end":{"line":35,"column":null}},"15":{"start":{"line":39,"column":4},"end":{"line":39,"column":null}},"16":{"start":{"line":40,"column":4},"end":{"line":40,"column":null}},"17":{"start":{"line":41,"column":4},"end":{"line":41,"column":null}},"18":{"start":{"line":45,"column":17},"end":{"line":45,"column":null}},"19":{"start":{"line":46,"column":4},"end":{"line":46,"column":null}},"20":{"start":{"line":50,"column":4},"end":{"line":52,"column":null}},"21":{"start":{"line":51,"column":6},"end":{"line":51,"column":null}},"22":{"start":{"line":55,"column":2},"end":{"line":68,"column":null}},"23":{"start":{"line":56,"column":23},"end":{"line":56,"column":null}},"24":{"start":{"line":57,"column":4},"end":{"line":59,"column":null}},"25":{"start":{"line":58,"column":6},"end":{"line":58,"column":null}},"26":{"start":{"line":61,"column":23},"end":{"line":61,"column":null}},"27":{"start":{"line":62,"column":4},"end":{"line":64,"column":null}},"28":{"start":{"line":63,"column":6},"end":{"line":63,"column":null}},"29":{"start":{"line":66,"column":4},"end":{"line":66,"column":null}},"30":{"start":{"line":67,"column":4},"end":{"line":67,"column":null}},"31":{"start":{"line":70,"column":2},"end":{"line":72,"column":null}},"32":{"start":{"line":71,"column":4},"end":{"line":71,"column":null}},"33":{"start":{"line":74,"column":8},"end":{"line":74,"column":null}},"34":{"start":{"line":74,"column":32},"end":{"line":74,"column":62}},"35":{"start":{"line":76,"column":2},"end":{"line":84,"column":null}}},"fnMap":{"0":{"name":"useTheme","decl":{"start":{"line":10,"column":16},"end":{"line":10,"column":27}},"loc":{"start":{"line":10,"column":27},"end":{"line":85,"column":null}},"line":10},"1":{"name":"applyTheme","decl":{"start":{"line":13,"column":11},"end":{"line":13,"column":24}},"loc":{"start":{"line":13,"column":24},"end":{"line":30,"column":null}},"line":13},"2":{"name":"setTheme","decl":{"start":{"line":32,"column":11},"end":{"line":32,"column":20}},"loc":{"start":{"line":32,"column":37},"end":{"line":36,"column":null}},"line":32},"3":{"name":"setThemeColor","decl":{"start":{"line":38,"column":11},"end":{"line":38,"column":25}},"loc":{"start":{"line":38,"column":44},"end":{"line":42,"column":null}},"line":38},"4":{"name":"toggleTheme","decl":{"start":{"line":44,"column":11},"end":{"line":44,"column":25}},"loc":{"start":{"line":44,"column":25},"end":{"line":47,"column":null}},"line":44},"5":{"name":"handleSystemChange","decl":{"start":{"line":49,"column":11},"end":{"line":49,"column":32}},"loc":{"start":{"line":49,"column":32},"end":{"line":53,"column":null}},"line":49},"6":{"name":"(anonymous_6)","decl":{"start":{"line":55,"column":12},"end":{"line":55,"column":18}},"loc":{"start":{"line":55,"column":18},"end":{"line":68,"column":3}},"line":55},"7":{"name":"(anonymous_7)","decl":{"start":{"line":70,"column":14},"end":{"line":70,"column":20}},"loc":{"start":{"line":70,"column":20},"end":{"line":72,"column":3}},"line":70},"8":{"name":"(anonymous_8)","decl":{"start":{"line":74,"column":26},"end":{"line":74,"column":32}},"loc":{"start":{"line":74,"column":32},"end":{"line":74,"column":62}},"line":74}},"branchMap":{"0":{"loc":{"start":{"line":15,"column":19},"end":{"line":15,"column":null}},"type":"binary-expr","locations":[{"start":{"line":15,"column":19},"end":{"line":15,"column":46}},{"start":{"line":15,"column":46},"end":{"line":15,"column":72}},{"start":{"line":15,"column":72},"end":{"line":15,"column":null}}],"line":15},"1":{"loc":{"start":{"line":17,"column":26},"end":{"line":17,"column":null}},"type":"cond-expr","locations":[{"start":{"line":17,"column":35},"end":{"line":17,"column":44}},{"start":{"line":17,"column":44},"end":{"line":17,"column":null}}],"line":17},"2":{"loc":{"start":{"line":20,"column":4},"end":{"line":24,"column":null}},"type":"if","locations":[{"start":{"line":20,"column":4},"end":{"lLine truncated
|
||||
,"D:\\code\\ai\\LagZero\\src\\utils\\protocol.ts": {"path":"D:\\code\\ai\\LagZero\\src\\utils\\protocol.ts","statementMap":{"0":{"start":{"line":32,"column":2},"end":{"line":32,"column":null}},"1":{"start":{"line":33,"column":2},"end":{"line":33,"column":null}},"2":{"start":{"line":33,"column":13},"end":{"line":33,"column":null}},"3":{"start":{"line":36,"column":2},"end":{"line":53,"column":null}},"4":{"start":{"line":37,"column":4},"end":{"line":52,"column":null}},"5":{"start":{"line":46,"column":24},"end":{"line":46,"column":null}},"6":{"start":{"line":47,"column":8},"end":{"line":49,"column":null}},"7":{"start":{"line":48,"column":12},"end":{"line":48,"column":null}},"8":{"start":{"line":55,"column":2},"end":{"line":63,"column":null}},"9":{"start":{"line":56,"column":4},"end":{"line":56,"column":null}},"10":{"start":{"line":57,"column":2},"end":{"line":63,"column":null}},"11":{"start":{"line":58,"column":4},"end":{"line":58,"column":null}},"12":{"start":{"line":59,"column":2},"end":{"line":63,"column":null}},"13":{"start":{"line":60,"column":4},"end":{"line":60,"column":null}},"14":{"start":{"line":61,"column":2},"end":{"line":63,"column":null}},"15":{"start":{"line":62,"column":4},"end":{"line":62,"column":null}},"16":{"start":{"line":64,"column":2},"end":{"line":64,"column":null}},"17":{"start":{"line":68,"column":4},"end":{"line":68,"column":null}},"18":{"start":{"line":69,"column":4},"end":{"line":69,"column":null}},"19":{"start":{"line":69,"column":18},"end":{"line":69,"column":null}},"20":{"start":{"line":72,"column":4},"end":{"line":82,"column":null}},"21":{"start":{"line":73,"column":8},"end":{"line":81,"column":null}},"22":{"start":{"line":74,"column":28},"end":{"line":74,"column":null}},"23":{"start":{"line":76,"column":12},"end":{"line":78,"column":null}},"24":{"start":{"line":77,"column":16},"end":{"line":77,"column":null}},"25":{"start":{"line":84,"column":18},"end":{"line":84,"column":null}},"26":{"start":{"line":85,"column":34},"end":{"line":85,"column":null}},"27":{"start":{"line":87,"column":4},"end":{"line":92,"column":null}},"28":{"start":{"line":88,"column":23},"end":{"line":88,"column":null}},"29":{"start":{"line":89,"column":8},"end":{"line":91,"column":null}},"30":{"start":{"line":90,"column":12},"end":{"line":90,"column":null}},"31":{"start":{"line":94,"column":4},"end":{"line":94,"column":null}},"32":{"start":{"line":98,"column":2},"end":{"line":120,"column":null}},"33":{"start":{"line":99,"column":19},"end":{"line":99,"column":null}},"34":{"start":{"line":100,"column":20},"end":{"line":100,"column":null}},"35":{"start":{"line":101,"column":19},"end":{"line":101,"column":null}},"36":{"start":{"line":102,"column":4},"end":{"line":116,"column":null}},"37":{"start":{"line":118,"column":4},"end":{"line":118,"column":null}},"38":{"start":{"line":119,"column":4},"end":{"line":119,"column":null}},"39":{"start":{"line":124,"column":2},"end":{"line":149,"column":null}},"40":{"start":{"line":125,"column":16},"end":{"line":125,"column":null}},"41":{"start":{"line":126,"column":19},"end":{"line":126,"column":null}},"42":{"start":{"line":127,"column":4},"end":{"line":145,"column":null}},"43":{"start":{"line":147,"column":6},"end":{"line":147,"column":null}},"44":{"start":{"line":148,"column":6},"end":{"line":148,"column":null}},"45":{"start":{"line":153,"column":4},"end":{"line":189,"column":null}},"46":{"start":{"line":154,"column":18},"end":{"line":154,"column":null}},"47":{"start":{"line":155,"column":26},"end":{"line":155,"column":null}},"48":{"start":{"line":156,"column":18},"end":{"line":156,"column":null}},"49":{"start":{"line":157,"column":8},"end":{"line":160,"column":null}},"50":{"start":{"line":158,"column":12},"end":{"line":158,"column":null}},"51":{"start":{"line":159,"column":12},"end":{"line":159,"column":null}},"52":{"start":{"line":162,"column":22},"end":{"line":162,"column":null}},"53":{"start":{"line":163,"column":8},"end":{"line":165,"column":null}},"54":{"start":{"line":164,"column":13},"end":{"line":164,"column":null}},"55":{"start":{"line":167,"column":22},"end":{"line":167,"column":null}},"56":{"start":{"line":168,"column":8},"end":{"line":168,"column":null}},"57":{"start":{"line":168,"column":30},"end":{"line":168,"column":null}},"58":{"start":{"line":170,"column":25},"end":{"line":170,"column":null}},"59":{"start":{"line":171,"column":27},"end":{"line":171,"column":null}},"60":{"start":{"line":172,"column":8},"end":{"line":172,"column":null}},"61":{"start":{"line":172,"column":38},"end":{"line":172,"column":null}},"62":{"start":{"line":174,"column":25},"end":{"line":174,"column":null}},"63":{"start":{"line":175,"column":27},"end":{"line":175,"column":null}},"64":{"start":{"line":177,"column":8},"end":{"line":177,"column":null}},"65":{"start":{"line":177,"column":58},"end":{"line":177,"column":null}},"66":{"start":{"line":179,"column":8},"end":{"line":186,"column":null}},"67":{"start":{"line":188,"column":8},"end":{"line":188,"column":null}},"68":{"start":{"line":193Line truncated
|
||||
,"D:\\code\\ai\\LagZero\\src\\utils\\singbox-config.ts": {"path":"D:\\code\\ai\\LagZero\\src\\utils\\singbox-config.ts","statementMap":{"0":{"start":{"line":29,"column":24},"end":{"line":29,"column":null}},"1":{"start":{"line":30,"column":20},"end":{"line":30,"column":null}},"2":{"start":{"line":31,"column":24},"end":{"line":31,"column":null}},"3":{"start":{"line":32,"column":25},"end":{"line":32,"column":null}},"4":{"start":{"line":35,"column":32},"end":{"line":92,"column":null}},"5":{"start":{"line":95,"column":23},"end":{"line":95,"column":null}},"6":{"start":{"line":96,"column":2},"end":{"line":96,"column":null}},"7":{"start":{"line":100,"column":2},"end":{"line":104,"column":null}},"8":{"start":{"line":106,"column":2},"end":{"line":111,"column":null}},"9":{"start":{"line":107,"column":5},"end":{"line":110,"column":null}},"10":{"start":{"line":114,"column":2},"end":{"line":118,"column":null}},"11":{"start":{"line":120,"column":2},"end":{"line":147,"column":null}},"12":{"start":{"line":122,"column":27},"end":{"line":122,"column":null}},"13":{"start":{"line":124,"column":6},"end":{"line":138,"column":null}},"14":{"start":{"line":126,"column":10},"end":{"line":129,"column":null}},"15":{"start":{"line":130,"column":10},"end":{"line":133,"column":null}},"16":{"start":{"line":141,"column":6},"end":{"line":146,"column":null}},"17":{"start":{"line":142,"column":10},"end":{"line":145,"column":null}},"18":{"start":{"line":149,"column":2},"end":{"line":149,"column":null}},"19":{"start":{"line":153,"column":15},"end":{"line":157,"column":null}},"20":{"start":{"line":159,"column":2},"end":{"line":240,"column":null}},"21":{"start":{"line":162,"column":6},"end":{"line":169,"column":null}},"22":{"start":{"line":171,"column":6},"end":{"line":191,"column":null}},"23":{"start":{"line":193,"column":8},"end":{"line":213,"column":null}},"24":{"start":{"line":215,"column":8},"end":{"line":233,"column":null}},"25":{"start":{"line":236,"column":6},"end":{"line":239,"column":null}},"26":{"start":{"line":244,"column":16},"end":{"line":246,"column":null}},"27":{"start":{"line":245,"column":14},"end":{"line":245,"column":36}},"28":{"start":{"line":248,"column":14},"end":{"line":248,"column":null}},"29":{"start":{"line":249,"column":2},"end":{"line":253,"column":null}},"30":{"start":{"line":250,"column":4},"end":{"line":250,"column":null}},"31":{"start":{"line":251,"column":18},"end":{"line":251,"column":null}},"32":{"start":{"line":252,"column":4},"end":{"line":252,"column":null}},"33":{"start":{"line":252,"column":21},"end":{"line":252,"column":null}},"34":{"start":{"line":255,"column":2},"end":{"line":255,"column":null}}},"fnMap":{"0":{"name":"generateSingboxConfig","decl":{"start":{"line":28,"column":16},"end":{"line":28,"column":38}},"loc":{"start":{"line":28,"column":76},"end":{"line":150,"column":null}},"line":28},"1":{"name":"convertNodeToOutbound","decl":{"start":{"line":152,"column":9},"end":{"line":152,"column":31}},"loc":{"start":{"line":152,"column":54},"end":{"line":241,"column":null}},"line":152},"2":{"name":"normalizeProcessNames","decl":{"start":{"line":243,"column":9},"end":{"line":243,"column":31}},"loc":{"start":{"line":243,"column":62},"end":{"line":256,"column":null}},"line":243},"3":{"name":"(anonymous_3)","decl":{"start":{"line":245,"column":9},"end":{"line":245,"column":14}},"loc":{"start":{"line":245,"column":14},"end":{"line":245,"column":36}},"line":245},"4":{"name":"(anonymous_4)","decl":{"start":{"line":249,"column":16},"end":{"line":249,"column":21}},"loc":{"start":{"line":249,"column":21},"end":{"line":253,"column":3}},"line":249}},"branchMap":{"0":{"loc":{"start":{"line":30,"column":42},"end":{"line":30,"column":113}},"type":"cond-expr","locations":[{"start":{"line":30,"column":76},"end":{"line":30,"column":95}},{"start":{"line":30,"column":95},"end":{"line":30,"column":113}}],"line":30},"1":{"loc":{"start":{"line":47,"column":13},"end":{"line":49,"column":null}},"type":"cond-expr","locations":[{"start":{"line":47,"column":29},"end":{"line":47,"column":34}},{"start":{"line":47,"column":34},"end":{"line":49,"column":null}}],"line":47},"2":{"loc":{"start":{"line":50,"column":13},"end":{"line":50,"column":null}},"type":"cond-expr","locations":[{"start":{"line":50,"column":29},"end":{"line":50,"column":40}},{"start":{"line":50,"column":40},"end":{"line":50,"column":null}}],"line":50},"3":{"loc":{"start":{"line":90,"column":13},"end":{"line":90,"column":null}},"type":"cond-expr","locations":[{"start":{"line":90,"column":29},"end":{"line":90,"column":39}},{"start":{"line":90,"column":39},"end":{"line":90,"column":null}}],"line":90},"4":{"loc":{"start":{"line":106,"column":2},"end":{"line":111,"column":null}},"type":"if","locations":[{"start":{"line":106,"column":2},"end":{"line":111,"column":null}},{"start":{},"end":{}}],"line":106},"5":{"loc":{"start":{"line":120,"column":2},"end":{"line":147,"column":null}},"type":"if","locations":[{"start":{"line":120,"column":2},"end":{"line":147,"column":null}},{"staLine truncated
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for electron/categories</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> electron/categories</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">50% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>18/36</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">0% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>0/10</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">54.54% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>6/11</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">60% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>18/30</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file medium" data-value="manager.ts"><a href="manager.ts.html">manager.ts</a></td>
|
||||
<td data-value="50" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 50%"></div><div class="cover-empty" style="width: 50%"></div></div>
|
||||
</td>
|
||||
<td data-value="50" class="pct medium">50%</td>
|
||||
<td data-value="36" class="abs medium">18/36</td>
|
||||
<td data-value="0" class="pct low">0%</td>
|
||||
<td data-value="10" class="abs low">0/10</td>
|
||||
<td data-value="54.54" class="pct medium">54.54%</td>
|
||||
<td data-value="11" class="abs medium">6/11</td>
|
||||
<td data-value="60" class="pct medium">60%</td>
|
||||
<td data-value="30" class="abs medium">18/30</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for electron/categories/manager.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">electron/categories</a> manager.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">50% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>18/36</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">0% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>0/10</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">54.54% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>6/11</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">60% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>18/30</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-yes">8x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { ipcMain } from 'electron'
|
||||
import { DatabaseManager } from '../db'
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
parentId?: string
|
||||
rules?: string[]
|
||||
icon?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
export class CategoryManager {
|
||||
private db: DatabaseManager
|
||||
private categoriesCache: Category[] = []
|
||||
|
||||
constructor(db: DatabaseManager) {
|
||||
this.db = db
|
||||
this.refreshCache()
|
||||
this.registerIPC()
|
||||
}
|
||||
|
||||
private async refreshCache() {
|
||||
try {
|
||||
this.categoriesCache = await this.db.getAllCategories()
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > console.error('Failed to refresh categories cache:', e)</span>
|
||||
}
|
||||
}
|
||||
|
||||
public async getAll() {
|
||||
this.categoriesCache = await this.db.getAllCategories()
|
||||
return this.categoriesCache
|
||||
}
|
||||
|
||||
public async save(category: Category) {
|
||||
const result = await this.db.saveCategory(category)
|
||||
this.categoriesCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
public async delete(id: string) {
|
||||
const result = await this.db.deleteCategory(id)
|
||||
this.categoriesCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
public <span class="fstat-no" title="function not covered" >matchCategory(g</span>ameName: string, processNames: string | string[]): string | null {
|
||||
// Use cache for synchronous matching logic if needed, or just use current state
|
||||
const categories = <span class="cstat-no" title="statement not covered" >this.categoriesCache</span>
|
||||
const pNames = <span class="cstat-no" title="statement not covered" >Array.isArray(processNames) ? processNames : [processNames]</span>
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > for (const cat of categories) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (cat.rules && cat.rules.length > 0) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > for (const rule of cat.rules) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
const regex = <span class="cstat-no" title="statement not covered" >new RegExp(rule, 'i')</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (regex.test(gameName)) <span class="cstat-no" title="statement not covered" >return cat.id</span></span>
|
||||
<span class="cstat-no" title="statement not covered" > for (const pName of pNames) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (regex.test(pName)) <span class="cstat-no" title="statement not covered" >return cat.id</span></span>
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore invalid regex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
<span class="cstat-no" title="statement not covered" > return null</span>
|
||||
}
|
||||
|
||||
private registerIPC() {
|
||||
ipcMain.handle('categories:get-all', <span class="fstat-no" title="function not covered" >() => <span class="cstat-no" title="statement not covered" >t</span>his.getAll())</span>
|
||||
ipcMain.handle('categories:save', <span class="fstat-no" title="function not covered" >(_</span>, category: Category) => <span class="cstat-no" title="statement not covered" >this.save(category))</span>
|
||||
ipcMain.handle('categories:delete', <span class="fstat-no" title="function not covered" >(_</span>, id: string) => <span class="cstat-no" title="statement not covered" >this.delete(id))</span>
|
||||
ipcMain.handle('categories:match', <span class="fstat-no" title="function not covered" >(_</span>, name: string, processes: string[]) => <span class="cstat-no" title="statement not covered" >this.matchCategory(name, processes))</span>
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for electron/config</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> electron/config</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">77.14% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>27/35</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">75% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>9/12</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">90% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>9/10</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">81.81% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>27/33</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file medium" data-value="store.ts"><a href="store.ts.html">store.ts</a></td>
|
||||
<td data-value="77.14" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 77%"></div><div class="cover-empty" style="width: 23%"></div></div>
|
||||
</td>
|
||||
<td data-value="77.14" class="pct medium">77.14%</td>
|
||||
<td data-value="35" class="abs medium">27/35</td>
|
||||
<td data-value="75" class="pct medium">75%</td>
|
||||
<td data-value="12" class="abs medium">9/12</td>
|
||||
<td data-value="90" class="pct high">90%</td>
|
||||
<td data-value="10" class="abs high">9/10</td>
|
||||
<td data-value="81.81" class="pct high">81.81%</td>
|
||||
<td data-value="33" class="abs high">27/33</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for electron/config/store.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">electron/config</a> store.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">77.14% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>27/35</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">75% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>9/12</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">90% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>9/10</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">81.81% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>27/33</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">6x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import fs from 'fs-extra'
|
||||
import path from 'node:path'
|
||||
|
||||
export interface StoreOptions<T> {
|
||||
name: string
|
||||
defaults: T
|
||||
cwd: string
|
||||
backupCount?: number
|
||||
}
|
||||
|
||||
export class JsonStore<T> {
|
||||
private filePath: string
|
||||
private backupDir: string
|
||||
private data: T
|
||||
private options: StoreOptions<T>
|
||||
|
||||
constructor(options: StoreOptions<T>) {
|
||||
this.options = { backupCount: 5, ...options }
|
||||
this.filePath = path.join(this.options.cwd, `${this.options.name}.json`)
|
||||
this.backupDir = path.join(this.options.cwd, 'backups', this.options.name)
|
||||
this.data = this.options.defaults
|
||||
this.init()
|
||||
}
|
||||
|
||||
private init() {
|
||||
try {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (fs.existsSync(this.filePath)) {
|
||||
<span class="cstat-no" title="statement not covered" > this.data = fs.readJsonSync(this.filePath)</span>
|
||||
} else {
|
||||
this.save()
|
||||
}
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > console.error(`Failed to load store ${this.options.name}:`, e)</span>
|
||||
// Attempt to load latest backup if main file is corrupted?
|
||||
}
|
||||
}
|
||||
|
||||
public get(): T {
|
||||
return this.data
|
||||
}
|
||||
|
||||
public set(data: T) {
|
||||
this.data = data
|
||||
this.save()
|
||||
}
|
||||
|
||||
private async save() {
|
||||
try {
|
||||
await this.backup()
|
||||
await fs.writeJson(this.filePath, this.data, { spaces: 2 })
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > console.error(`Failed to save store ${this.options.name}:`, e)</span>
|
||||
}
|
||||
}
|
||||
|
||||
private async backup() {
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!this.options.backupCount || this.options.backupCount <= 0) <span class="cstat-no" title="statement not covered" >return</span>
|
||||
|
||||
try {
|
||||
await fs.ensureDir(this.backupDir)
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const backupPath = path.join(this.backupDir, `${this.options.name}-${timestamp}.json`)
|
||||
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
await fs.copy(this.filePath, backupPath)
|
||||
}
|
||||
|
||||
// Cleanup old backups
|
||||
const files = await fs.readdir(this.backupDir)
|
||||
const backups = files
|
||||
.filter(f => f.startsWith(this.options.name) && f.endsWith('.json'))
|
||||
.map(f => path.join(this.backupDir, f))
|
||||
.sort((a, b) => fs.statSync(b).mtime.getTime() - fs.statSync(a).mtime.getTime())
|
||||
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (backups.length > this.options.backupCount) {
|
||||
const toDelete = <span class="cstat-no" title="statement not covered" >backups.slice(this.options.backupCount)</span>
|
||||
<span class="cstat-no" title="statement not covered" > await Promise.all(toDelete.map(<span class="fstat-no" title="function not covered" >f => <span class="cstat-no" title="statement not covered" >f</span>s.remove(f))</span>)</span>
|
||||
}
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > console.error(`Backup failed for ${this.options.name}:`, e)</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 445 B |
@@ -0,0 +1,161 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for All files</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="prettify.css" />
|
||||
<link rel="stylesheet" href="base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1>All files</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">50.23% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>108/215</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">30.12% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>50/166</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">63.41% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>26/41</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">54% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>108/200</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file medium" data-value="electron/categories"><a href="electron/categories/index.html">electron/categories</a></td>
|
||||
<td data-value="50" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 50%"></div><div class="cover-empty" style="width: 50%"></div></div>
|
||||
</td>
|
||||
<td data-value="50" class="pct medium">50%</td>
|
||||
<td data-value="36" class="abs medium">18/36</td>
|
||||
<td data-value="0" class="pct low">0%</td>
|
||||
<td data-value="10" class="abs low">0/10</td>
|
||||
<td data-value="54.54" class="pct medium">54.54%</td>
|
||||
<td data-value="11" class="abs medium">6/11</td>
|
||||
<td data-value="60" class="pct medium">60%</td>
|
||||
<td data-value="30" class="abs medium">18/30</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file medium" data-value="electron/config"><a href="electron/config/index.html">electron/config</a></td>
|
||||
<td data-value="77.14" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 77%"></div><div class="cover-empty" style="width: 23%"></div></div>
|
||||
</td>
|
||||
<td data-value="77.14" class="pct medium">77.14%</td>
|
||||
<td data-value="35" class="abs medium">27/35</td>
|
||||
<td data-value="75" class="pct medium">75%</td>
|
||||
<td data-value="12" class="abs medium">9/12</td>
|
||||
<td data-value="90" class="pct high">90%</td>
|
||||
<td data-value="10" class="abs high">9/10</td>
|
||||
<td data-value="81.81" class="pct high">81.81%</td>
|
||||
<td data-value="33" class="abs high">27/33</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file medium" data-value="src/composables"><a href="src/composables/index.html">src/composables</a></td>
|
||||
<td data-value="58.33" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 58%"></div><div class="cover-empty" style="width: 42%"></div></div>
|
||||
</td>
|
||||
<td data-value="58.33" class="pct medium">58.33%</td>
|
||||
<td data-value="36" class="abs medium">21/36</td>
|
||||
<td data-value="47.36" class="pct low">47.36%</td>
|
||||
<td data-value="19" class="abs low">9/19</td>
|
||||
<td data-value="44.44" class="pct low">44.44%</td>
|
||||
<td data-value="9" class="abs low">4/9</td>
|
||||
<td data-value="60" class="pct medium">60%</td>
|
||||
<td data-value="35" class="abs medium">21/35</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file low" data-value="src/utils"><a href="src/utils/index.html">src/utils</a></td>
|
||||
<td data-value="38.88" class="pic low">
|
||||
<div class="chart"><div class="cover-fill" style="width: 38%"></div><div class="cover-empty" style="width: 62%"></div></div>
|
||||
</td>
|
||||
<td data-value="38.88" class="pct low">38.88%</td>
|
||||
<td data-value="108" class="abs low">42/108</td>
|
||||
<td data-value="25.6" class="pct low">25.6%</td>
|
||||
<td data-value="125" class="abs low">32/125</td>
|
||||
<td data-value="63.63" class="pct medium">63.63%</td>
|
||||
<td data-value="11" class="abs medium">7/11</td>
|
||||
<td data-value="41.17" class="pct low">41.17%</td>
|
||||
<td data-value="102" class="abs low">42/102</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="sorter.js"></script>
|
||||
<script src="block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
|
||||
@@ -0,0 +1,2 @@
|
||||
/* eslint-disable */
|
||||
window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V<U;++V){var ae=Z[V];if(ae.ignoreCase){ac=true}else{if(/[a-z]/i.test(ae.source.replace(/\\u[0-9a-f]{4}|\\x[0-9a-f]{2}|\\[^ux]/gi,""))){S=true;ac=false;break}}}var Y={b:8,t:9,n:10,v:11,f:12,r:13};function ab(ah){var ag=ah.charCodeAt(0);if(ag!==92){return ag}var af=ah.charAt(1);ag=Y[af];if(ag){return ag}else{if("0"<=af&&af<="7"){return parseInt(ah.substring(1),8)}else{if(af==="u"||af==="x"){return parseInt(ah.substring(2),16)}else{return ah.charCodeAt(1)}}}}function T(af){if(af<32){return(af<16?"\\x0":"\\x")+af.toString(16)}var ag=String.fromCharCode(af);if(ag==="\\"||ag==="-"||ag==="["||ag==="]"){ag="\\"+ag}return ag}function X(am){var aq=am.substring(1,am.length-1).match(new RegExp("\\\\u[0-9A-Fa-f]{4}|\\\\x[0-9A-Fa-f]{2}|\\\\[0-3][0-7]{0,2}|\\\\[0-7]{1,2}|\\\\[\\s\\S]|-|[^-\\\\]","g"));var ak=[];var af=[];var ao=aq[0]==="^";for(var ar=ao?1:0,aj=aq.length;ar<aj;++ar){var ah=aq[ar];if(/\\[bdsw]/i.test(ah)){ak.push(ah)}else{var ag=ab(ah);var al;if(ar+2<aj&&"-"===aq[ar+1]){al=ab(aq[ar+2]);ar+=2}else{al=ag}af.push([ag,al]);if(!(al<65||ag>122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;ar<af.length;++ar){var at=af[ar];if(at[0]<=ap[1]+1){ap[1]=Math.max(ap[1],at[1])}else{ai.push(ap=at)}}var an=["["];if(ao){an.push("^")}an.push.apply(an,ak);for(var ar=0;ar<ai.length;++ar){var at=ai[ar];an.push(T(at[0]));if(at[1]>at[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak<ah;++ak){var ag=aj[ak];if(ag==="("){++am}else{if("\\"===ag.charAt(0)){var af=+ag.substring(1);if(af&&af<=am){an[af]=-1}}}}for(var ak=1;ak<an.length;++ak){if(-1===an[ak]){an[ak]=++ad}}for(var ak=0,am=0;ak<ah;++ak){var ag=aj[ak];if(ag==="("){++am;if(an[am]===undefined){aj[ak]="(?:"}}else{if("\\"===ag.charAt(0)){var af=+ag.substring(1);if(af&&af<=am){aj[ak]="\\"+an[am]}}}}for(var ak=0,am=0;ak<ah;++ak){if("^"===aj[ak]&&"^"!==aj[ak+1]){aj[ak]=""}}if(al.ignoreCase&&S){for(var ak=0;ak<ah;++ak){var ag=aj[ak];var ai=ag.charAt(0);Line truncated
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 138 B |
@@ -0,0 +1,210 @@
|
||||
/* eslint-disable */
|
||||
var addSorting = (function() {
|
||||
'use strict';
|
||||
var cols,
|
||||
currentSort = {
|
||||
index: 0,
|
||||
desc: false
|
||||
};
|
||||
|
||||
// returns the summary table element
|
||||
function getTable() {
|
||||
return document.querySelector('.coverage-summary');
|
||||
}
|
||||
// returns the thead element of the summary table
|
||||
function getTableHeader() {
|
||||
return getTable().querySelector('thead tr');
|
||||
}
|
||||
// returns the tbody element of the summary table
|
||||
function getTableBody() {
|
||||
return getTable().querySelector('tbody');
|
||||
}
|
||||
// returns the th element for nth column
|
||||
function getNthColumn(n) {
|
||||
return getTableHeader().querySelectorAll('th')[n];
|
||||
}
|
||||
|
||||
function onFilterInput() {
|
||||
const searchValue = document.getElementById('fileSearch').value;
|
||||
const rows = document.getElementsByTagName('tbody')[0].children;
|
||||
|
||||
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
|
||||
// it will be treated as a plain text search
|
||||
let searchRegex;
|
||||
try {
|
||||
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
|
||||
} catch (error) {
|
||||
searchRegex = null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
let isMatch = false;
|
||||
|
||||
if (searchRegex) {
|
||||
// If a valid regex was created, use it for matching
|
||||
isMatch = searchRegex.test(row.textContent);
|
||||
} else {
|
||||
// Otherwise, fall back to the original plain text search
|
||||
isMatch = row.textContent
|
||||
.toLowerCase()
|
||||
.includes(searchValue.toLowerCase());
|
||||
}
|
||||
|
||||
row.style.display = isMatch ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// loads the search box
|
||||
function addSearchBox() {
|
||||
var template = document.getElementById('filterTemplate');
|
||||
var templateClone = template.content.cloneNode(true);
|
||||
templateClone.getElementById('fileSearch').oninput = onFilterInput;
|
||||
template.parentElement.appendChild(templateClone);
|
||||
}
|
||||
|
||||
// loads all columns
|
||||
function loadColumns() {
|
||||
var colNodes = getTableHeader().querySelectorAll('th'),
|
||||
colNode,
|
||||
cols = [],
|
||||
col,
|
||||
i;
|
||||
|
||||
for (i = 0; i < colNodes.length; i += 1) {
|
||||
colNode = colNodes[i];
|
||||
col = {
|
||||
key: colNode.getAttribute('data-col'),
|
||||
sortable: !colNode.getAttribute('data-nosort'),
|
||||
type: colNode.getAttribute('data-type') || 'string'
|
||||
};
|
||||
cols.push(col);
|
||||
if (col.sortable) {
|
||||
col.defaultDescSort = col.type === 'number';
|
||||
colNode.innerHTML =
|
||||
colNode.innerHTML + '<span class="sorter"></span>';
|
||||
}
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
// attaches a data attribute to every tr element with an object
|
||||
// of data values keyed by column name
|
||||
function loadRowData(tableRow) {
|
||||
var tableCols = tableRow.querySelectorAll('td'),
|
||||
colNode,
|
||||
col,
|
||||
data = {},
|
||||
i,
|
||||
val;
|
||||
for (i = 0; i < tableCols.length; i += 1) {
|
||||
colNode = tableCols[i];
|
||||
col = cols[i];
|
||||
val = colNode.getAttribute('data-value');
|
||||
if (col.type === 'number') {
|
||||
val = Number(val);
|
||||
}
|
||||
data[col.key] = val;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
// loads all row data
|
||||
function loadData() {
|
||||
var rows = getTableBody().querySelectorAll('tr'),
|
||||
i;
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
rows[i].data = loadRowData(rows[i]);
|
||||
}
|
||||
}
|
||||
// sorts the table using the data for the ith column
|
||||
function sortByIndex(index, desc) {
|
||||
var key = cols[index].key,
|
||||
sorter = function(a, b) {
|
||||
a = a.data[key];
|
||||
b = b.data[key];
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
},
|
||||
finalSorter = sorter,
|
||||
tableBody = document.querySelector('.coverage-summary tbody'),
|
||||
rowNodes = tableBody.querySelectorAll('tr'),
|
||||
rows = [],
|
||||
i;
|
||||
|
||||
if (desc) {
|
||||
finalSorter = function(a, b) {
|
||||
return -1 * sorter(a, b);
|
||||
};
|
||||
}
|
||||
|
||||
for (i = 0; i < rowNodes.length; i += 1) {
|
||||
rows.push(rowNodes[i]);
|
||||
tableBody.removeChild(rowNodes[i]);
|
||||
}
|
||||
|
||||
rows.sort(finalSorter);
|
||||
|
||||
for (i = 0; i < rows.length; i += 1) {
|
||||
tableBody.appendChild(rows[i]);
|
||||
}
|
||||
}
|
||||
// removes sort indicators for current column being sorted
|
||||
function removeSortIndicators() {
|
||||
var col = getNthColumn(currentSort.index),
|
||||
cls = col.className;
|
||||
|
||||
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
|
||||
col.className = cls;
|
||||
}
|
||||
// adds sort indicators for current column being sorted
|
||||
function addSortIndicators() {
|
||||
getNthColumn(currentSort.index).className += currentSort.desc
|
||||
? ' sorted-desc'
|
||||
: ' sorted';
|
||||
}
|
||||
// adds event listeners for all sorter widgets
|
||||
function enableUI() {
|
||||
var i,
|
||||
el,
|
||||
ithSorter = function ithSorter(i) {
|
||||
var col = cols[i];
|
||||
|
||||
return function() {
|
||||
var desc = col.defaultDescSort;
|
||||
|
||||
if (currentSort.index === i) {
|
||||
desc = !currentSort.desc;
|
||||
}
|
||||
sortByIndex(i, desc);
|
||||
removeSortIndicators();
|
||||
currentSort.index = i;
|
||||
currentSort.desc = desc;
|
||||
addSortIndicators();
|
||||
};
|
||||
};
|
||||
for (i = 0; i < cols.length; i += 1) {
|
||||
if (cols[i].sortable) {
|
||||
// add the click event handler on the th so users
|
||||
// dont have to click on those tiny arrows
|
||||
el = getNthColumn(i).querySelector('.sorter').parentElement;
|
||||
if (el.addEventListener) {
|
||||
el.addEventListener('click', ithSorter(i));
|
||||
} else {
|
||||
el.attachEvent('onclick', ithSorter(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// adds sorting functionality to the UI
|
||||
return function() {
|
||||
if (!getTable()) {
|
||||
return;
|
||||
}
|
||||
cols = loadColumns();
|
||||
loadData();
|
||||
addSearchBox();
|
||||
addSortIndicators();
|
||||
enableUI();
|
||||
};
|
||||
})();
|
||||
|
||||
window.addEventListener('load', addSorting);
|
||||
@@ -0,0 +1,116 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/composables</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> src/composables</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">58.33% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>21/36</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">47.36% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>9/19</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">44.44% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>4/9</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">60% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>21/35</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file medium" data-value="useTheme.ts"><a href="useTheme.ts.html">useTheme.ts</a></td>
|
||||
<td data-value="58.33" class="pic medium">
|
||||
<div class="chart"><div class="cover-fill" style="width: 58%"></div><div class="cover-empty" style="width: 42%"></div></div>
|
||||
</td>
|
||||
<td data-value="58.33" class="pct medium">58.33%</td>
|
||||
<td data-value="36" class="abs medium">21/36</td>
|
||||
<td data-value="47.36" class="pct low">47.36%</td>
|
||||
<td data-value="19" class="abs low">9/19</td>
|
||||
<td data-value="44.44" class="pct low">44.44%</td>
|
||||
<td data-value="9" class="abs low">4/9</td>
|
||||
<td data-value="60" class="pct medium">60%</td>
|
||||
<td data-value="35" class="abs medium">21/35</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/composables/useTheme.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/composables</a> useTheme.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">58.33% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>21/36</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">47.36% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>9/19</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">44.44% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>4/9</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">60% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>21/35</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line medium'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">7x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-yes">10x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">9x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'auto'
|
||||
export type ThemeColor = 'green' | 'blue' | 'purple' | 'orange' | 'red'
|
||||
|
||||
const theme = ref<Theme>('auto')
|
||||
const resolvedTheme = ref<'light' | 'dark'>('dark') // Default to dark
|
||||
const themeColor = ref<ThemeColor>('green')
|
||||
|
||||
export function useTheme() {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
function applyTheme() {
|
||||
const root = document.documentElement
|
||||
const isDark = theme.value === 'dark' || (theme.value === 'auto' && mediaQuery.matches)
|
||||
|
||||
resolvedTheme.value = isDark ? 'dark' : 'light'
|
||||
|
||||
// Apply dark mode
|
||||
if (isDark) {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
|
||||
// Apply theme color
|
||||
// Remove previous theme classes
|
||||
root.classList.remove('theme-green', 'theme-blue', 'theme-purple', 'theme-orange', 'theme-red')
|
||||
root.classList.add(`theme-${themeColor.value}`)
|
||||
}
|
||||
|
||||
function setTheme(newTheme: Theme) {
|
||||
theme.value = newTheme
|
||||
localStorage.setItem('theme', newTheme)
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
function <span class="fstat-no" title="function not covered" >setThemeColor(c</span>olor: ThemeColor) {
|
||||
<span class="cstat-no" title="statement not covered" > themeColor.value = color</span>
|
||||
<span class="cstat-no" title="statement not covered" > localStorage.setItem('themeColor', color)</span>
|
||||
<span class="cstat-no" title="statement not covered" > applyTheme()</span>
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const next = resolvedTheme.value === 'dark' ? 'light' : 'dark'
|
||||
setTheme(next)
|
||||
}
|
||||
|
||||
function <span class="fstat-no" title="function not covered" >handleSystemChange() {</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (theme.value === 'auto') {</span>
|
||||
<span class="cstat-no" title="statement not covered" > applyTheme()</span>
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(<span class="fstat-no" title="function not covered" >() => {</span>
|
||||
const savedTheme = <span class="cstat-no" title="statement not covered" >localStorage.getItem('theme') as Theme</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (savedTheme && ['light', 'dark', 'auto'].includes(savedTheme)) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > theme.value = savedTheme</span>
|
||||
}
|
||||
|
||||
const savedColor = <span class="cstat-no" title="statement not covered" >localStorage.getItem('themeColor') as ThemeColor</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (savedColor && ['green', 'blue', 'purple', 'orange', 'red'].includes(savedColor)) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > themeColor.value = savedColor</span>
|
||||
}
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > applyTheme()</span>
|
||||
<span class="cstat-no" title="statement not covered" > mediaQuery.addEventListener('change', handleSystemChange)</span>
|
||||
})
|
||||
|
||||
onUnmounted(<span class="fstat-no" title="function not covered" >() => {</span>
|
||||
<span class="cstat-no" title="statement not covered" > mediaQuery.removeEventListener('change', handleSystemChange)</span>
|
||||
})
|
||||
|
||||
const isDark = computed(<span class="fstat-no" title="function not covered" >() => <span class="cstat-no" title="statement not covered" >r</span>esolvedTheme.value === 'dark')</span>
|
||||
|
||||
return {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
isDark,
|
||||
themeColor,
|
||||
setTheme,
|
||||
setThemeColor,
|
||||
toggleTheme
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/utils</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> src/utils</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">38.88% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>42/108</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">25.6% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>32/125</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">63.63% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>7/11</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">41.17% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>42/102</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line low'></div>
|
||||
<div class="pad1">
|
||||
<table class="coverage-summary">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
|
||||
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
|
||||
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
|
||||
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
|
||||
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
|
||||
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
|
||||
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody><tr>
|
||||
<td class="file low" data-value="protocol.ts"><a href="protocol.ts.html">protocol.ts</a></td>
|
||||
<td data-value="19.17" class="pic low">
|
||||
<div class="chart"><div class="cover-fill" style="width: 19%"></div><div class="cover-empty" style="width: 81%"></div></div>
|
||||
</td>
|
||||
<td data-value="19.17" class="pct low">19.17%</td>
|
||||
<td data-value="73" class="abs low">14/73</td>
|
||||
<td data-value="13.63" class="pct low">13.63%</td>
|
||||
<td data-value="66" class="abs low">9/66</td>
|
||||
<td data-value="33.33" class="pct low">33.33%</td>
|
||||
<td data-value="6" class="abs low">2/6</td>
|
||||
<td data-value="20.58" class="pct low">20.58%</td>
|
||||
<td data-value="68" class="abs low">14/68</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td class="file high" data-value="singbox-config.ts"><a href="singbox-config.ts.html">singbox-config.ts</a></td>
|
||||
<td data-value="80" class="pic high">
|
||||
<div class="chart"><div class="cover-fill" style="width: 80%"></div><div class="cover-empty" style="width: 20%"></div></div>
|
||||
</td>
|
||||
<td data-value="80" class="pct high">80%</td>
|
||||
<td data-value="35" class="abs high">28/35</td>
|
||||
<td data-value="38.98" class="pct low">38.98%</td>
|
||||
<td data-value="59" class="abs low">23/59</td>
|
||||
<td data-value="100" class="pct high">100%</td>
|
||||
<td data-value="5" class="abs high">5/5</td>
|
||||
<td data-value="82.35" class="pct high">82.35%</td>
|
||||
<td data-value="34" class="abs high">28/34</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,721 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/utils/protocol.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/utils</a> protocol.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">19.17% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>14/73</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">13.63% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>9/66</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">33.33% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>2/6</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">20.58% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>14/68</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line low'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a>
|
||||
<a name='L140'></a><a href='#L140'>140</a>
|
||||
<a name='L141'></a><a href='#L141'>141</a>
|
||||
<a name='L142'></a><a href='#L142'>142</a>
|
||||
<a name='L143'></a><a href='#L143'>143</a>
|
||||
<a name='L144'></a><a href='#L144'>144</a>
|
||||
<a name='L145'></a><a href='#L145'>145</a>
|
||||
<a name='L146'></a><a href='#L146'>146</a>
|
||||
<a name='L147'></a><a href='#L147'>147</a>
|
||||
<a name='L148'></a><a href='#L148'>148</a>
|
||||
<a name='L149'></a><a href='#L149'>149</a>
|
||||
<a name='L150'></a><a href='#L150'>150</a>
|
||||
<a name='L151'></a><a href='#L151'>151</a>
|
||||
<a name='L152'></a><a href='#L152'>152</a>
|
||||
<a name='L153'></a><a href='#L153'>153</a>
|
||||
<a name='L154'></a><a href='#L154'>154</a>
|
||||
<a name='L155'></a><a href='#L155'>155</a>
|
||||
<a name='L156'></a><a href='#L156'>156</a>
|
||||
<a name='L157'></a><a href='#L157'>157</a>
|
||||
<a name='L158'></a><a href='#L158'>158</a>
|
||||
<a name='L159'></a><a href='#L159'>159</a>
|
||||
<a name='L160'></a><a href='#L160'>160</a>
|
||||
<a name='L161'></a><a href='#L161'>161</a>
|
||||
<a name='L162'></a><a href='#L162'>162</a>
|
||||
<a name='L163'></a><a href='#L163'>163</a>
|
||||
<a name='L164'></a><a href='#L164'>164</a>
|
||||
<a name='L165'></a><a href='#L165'>165</a>
|
||||
<a name='L166'></a><a href='#L166'>166</a>
|
||||
<a name='L167'></a><a href='#L167'>167</a>
|
||||
<a name='L168'></a><a href='#L168'>168</a>
|
||||
<a name='L169'></a><a href='#L169'>169</a>
|
||||
<a name='L170'></a><a href='#L170'>170</a>
|
||||
<a name='L171'></a><a href='#L171'>171</a>
|
||||
<a name='L172'></a><a href='#L172'>172</a>
|
||||
<a name='L173'></a><a href='#L173'>173</a>
|
||||
<a name='L174'></a><a href='#L174'>174</a>
|
||||
<a name='L175'></a><a href='#L175'>175</a>
|
||||
<a name='L176'></a><a href='#L176'>176</a>
|
||||
<a name='L177'></a><a href='#L177'>177</a>
|
||||
<a name='L178'></a><a href='#L178'>178</a>
|
||||
<a name='L179'></a><a href='#L179'>179</a>
|
||||
<a name='L180'></a><a href='#L180'>180</a>
|
||||
<a name='L181'></a><a href='#L181'>181</a>
|
||||
<a name='L182'></a><a href='#L182'>182</a>
|
||||
<a name='L183'></a><a href='#L183'>183</a>
|
||||
<a name='L184'></a><a href='#L184'>184</a>
|
||||
<a name='L185'></a><a href='#L185'>185</a>
|
||||
<a name='L186'></a><a href='#L186'>186</a>
|
||||
<a name='L187'></a><a href='#L187'>187</a>
|
||||
<a name='L188'></a><a href='#L188'>188</a>
|
||||
<a name='L189'></a><a href='#L189'>189</a>
|
||||
<a name='L190'></a><a href='#L190'>190</a>
|
||||
<a name='L191'></a><a href='#L191'>191</a>
|
||||
<a name='L192'></a><a href='#L192'>192</a>
|
||||
<a name='L193'></a><a href='#L193'>193</a>
|
||||
<a name='L194'></a><a href='#L194'>194</a>
|
||||
<a name='L195'></a><a href='#L195'>195</a>
|
||||
<a name='L196'></a><a href='#L196'>196</a>
|
||||
<a name='L197'></a><a href='#L197'>197</a>
|
||||
<a name='L198'></a><a href='#L198'>198</a>
|
||||
<a name='L199'></a><a href='#L199'>199</a>
|
||||
<a name='L200'></a><a href='#L200'>200</a>
|
||||
<a name='L201'></a><a href='#L201'>201</a>
|
||||
<a name='L202'></a><a href='#L202'>202</a>
|
||||
<a name='L203'></a><a href='#L203'>203</a>
|
||||
<a name='L204'></a><a href='#L204'>204</a>
|
||||
<a name='L205'></a><a href='#L205'>205</a>
|
||||
<a name='L206'></a><a href='#L206'>206</a>
|
||||
<a name='L207'></a><a href='#L207'>207</a>
|
||||
<a name='L208'></a><a href='#L208'>208</a>
|
||||
<a name='L209'></a><a href='#L209'>209</a>
|
||||
<a name='L210'></a><a href='#L210'>210</a>
|
||||
<a name='L211'></a><a href='#L211'>211</a>
|
||||
<a name='L212'></a><a href='#L212'>212</a>
|
||||
<a name='L213'></a><a href='#L213'>213</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">2x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import { Base64 } from 'js-base64'
|
||||
|
||||
export interface NodeConfig {
|
||||
id?: string // Added ID for management
|
||||
type: string
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
uuid?: string
|
||||
password?: string
|
||||
method?: string
|
||||
plugin?: string
|
||||
plugin_opts?: string
|
||||
network?: string
|
||||
security?: string
|
||||
path?: string
|
||||
host?: string
|
||||
tls?: {
|
||||
enabled: boolean
|
||||
server_name?: string
|
||||
insecure?: boolean
|
||||
utls?: {
|
||||
enabled: boolean
|
||||
fingerprint: string
|
||||
}
|
||||
}
|
||||
flow?: string
|
||||
packet_encoding?: string
|
||||
}
|
||||
|
||||
export function parseShareLink(link: string): NodeConfig | null {
|
||||
link = link.trim()
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!link) <span class="cstat-no" title="statement not covered" >return null</span>
|
||||
|
||||
// Try to decode if it looks like a base64 string (no protocol prefix, no spaces)
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (!link.includes('://') && <span class="branch-1 cbranch-no" title="branch not covered" >/^[A-Za-z0-9+/=]+$/.test(link)) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
// This might be a subscription content (multiple links)
|
||||
// But parseShareLink is designed for single link.
|
||||
// We will handle bulk import in a separate function or upper layer.
|
||||
// However, if it's a single base64 string, it might be a single node (rare for vmess/vless, common for SS)
|
||||
// or a subscription response.
|
||||
|
||||
// Let's assume the caller handles bulk splitting if it's a subscription.
|
||||
// But if the user pastes a raw base64 string which decodes to "vmess://...", we should handle it.
|
||||
const decoded = <span class="cstat-no" title="statement not covered" >Base64.decode(link).trim()</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (decoded.includes('://')) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > return parseShareLink(decoded)</span>
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (link.startsWith('vmess://')) {
|
||||
return parseVMess(link)
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>} else if (link.startsWith('vless://')) {
|
||||
<span class="cstat-no" title="statement not covered" > return parseVLESS(link)</span>
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>} else if (link.startsWith('ss://')) {
|
||||
<span class="cstat-no" title="statement not covered" > return parseShadowsocks(link)</span>
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>} else if (link.startsWith('trojan://')) {
|
||||
<span class="cstat-no" title="statement not covered" > return parseTrojan(link)</span>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function <span class="fstat-no" title="function not covered" >parseBatchLinks(c</span>ontent: string): NodeConfig[] {
|
||||
<span class="cstat-no" title="statement not covered" > content = content.trim()</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (!content) <span class="cstat-no" title="statement not covered" >return []</span></span>
|
||||
|
||||
// Try Base64 decode first if the whole content looks like Base64
|
||||
<span class="cstat-no" title="statement not covered" > if (!content.includes('://') && /^[A-Za-z0-9+/=\s]+$/.test(content)) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
const decoded = <span class="cstat-no" title="statement not covered" >Base64.decode(content)</span>
|
||||
// If decoding yields readable characters and contains protocols
|
||||
<span class="cstat-no" title="statement not covered" > if (decoded.includes('://')) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > content = decoded</span>
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a valid base64 or not subscription content
|
||||
}
|
||||
}
|
||||
|
||||
const lines = <span class="cstat-no" title="statement not covered" >content.split(/[\r\n]+/)</span>
|
||||
const configs: NodeConfig[] = <span class="cstat-no" title="statement not covered" >[]</span>
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > for (const line of lines) {</span>
|
||||
const config = <span class="cstat-no" title="statement not covered" >parseShareLink(line)</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (config) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > configs.push(config)</span>
|
||||
}
|
||||
}
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > return configs</span>
|
||||
}
|
||||
|
||||
function parseVMess(link: string): NodeConfig | null {
|
||||
try {
|
||||
const base64 = link.replace('vmess://', '')
|
||||
const jsonStr = Base64.decode(base64)
|
||||
const config = JSON.parse(jsonStr)
|
||||
return {
|
||||
type: 'vmess',
|
||||
tag: config.ps || <span class="branch-1 cbranch-no" title="branch not covered" >'VMess Node',</span>
|
||||
server: config.add,
|
||||
server_port: Number(config.port),
|
||||
uuid: config.id,
|
||||
security: 'auto',
|
||||
network: config.net,
|
||||
path: config.path,
|
||||
host: config.host,
|
||||
tls: {
|
||||
enabled: config.tls === 'tls',
|
||||
server_name: config.host
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > console.error('Failed to parse VMess', e)</span>
|
||||
<span class="cstat-no" title="statement not covered" > return null</span>
|
||||
}
|
||||
}
|
||||
|
||||
function <span class="fstat-no" title="function not covered" >parseVLESS(l</span>ink: string): NodeConfig | null {
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
const url = <span class="cstat-no" title="statement not covered" >new URL(link)</span>
|
||||
const params = <span class="cstat-no" title="statement not covered" >url.searchParams</span>
|
||||
<span class="cstat-no" title="statement not covered" > return {</span>
|
||||
type: 'vless',
|
||||
tag: decodeURIComponent(url.hash.slice(1)) || 'VLESS Node',
|
||||
server: url.hostname,
|
||||
server_port: Number(url.port),
|
||||
uuid: url.username,
|
||||
network: params.get('type') || 'tcp',
|
||||
security: params.get('security') || 'none',
|
||||
flow: params.get('flow') || undefined,
|
||||
tls: {
|
||||
enabled: params.get('security') === 'tls' || params.get('security') === 'reality',
|
||||
server_name: params.get('sni') || undefined,
|
||||
insecure: params.get('allowInsecure') === '1',
|
||||
utls: {
|
||||
enabled: !!params.get('fp'),
|
||||
fingerprint: params.get('fp') || 'chrome'
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > console.error('Failed to parse VLESS', e)</span>
|
||||
<span class="cstat-no" title="statement not covered" > return null</span>
|
||||
}
|
||||
}
|
||||
|
||||
function <span class="fstat-no" title="function not covered" >parseShadowsocks(l</span>ink: string): NodeConfig | null {
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
let uri = <span class="cstat-no" title="statement not covered" >link.replace('ss://', '')</span>
|
||||
const hashIndex = <span class="cstat-no" title="statement not covered" >uri.indexOf('#')</span>
|
||||
let tag = <span class="cstat-no" title="statement not covered" >'Shadowsocks Node'</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (hashIndex !== -1) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > tag = decodeURIComponent(uri.substring(hashIndex + 1)) || 'Shadowsocks Node'</span>
|
||||
<span class="cstat-no" title="statement not covered" > uri = uri.substring(0, hashIndex)</span>
|
||||
}
|
||||
|
||||
let decoded = <span class="cstat-no" title="statement not covered" >uri</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (!uri.includes('@')) {</span>
|
||||
<span class="cstat-no" title="statement not covered" > decoded = Base64.decode(uri)</span>
|
||||
}
|
||||
|
||||
const parts = <span class="cstat-no" title="statement not covered" >decoded.split('@')</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (parts.length < 2) <span class="cstat-no" title="statement not covered" >return null</span></span>
|
||||
|
||||
const userPart = <span class="cstat-no" title="statement not covered" >parts[0]</span>
|
||||
const serverPart = <span class="cstat-no" title="statement not covered" >parts[1]</span>
|
||||
<span class="cstat-no" title="statement not covered" > if (!userPart || !serverPart) <span class="cstat-no" title="statement not covered" >return null</span></span>
|
||||
|
||||
const userInfo = <span class="cstat-no" title="statement not covered" >userPart.split(':')</span>
|
||||
const serverInfo = <span class="cstat-no" title="statement not covered" >serverPart.split(':')</span>
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > if (userInfo.length < 2 || serverInfo.length < 2) <span class="cstat-no" title="statement not covered" >return null</span></span>
|
||||
|
||||
<span class="cstat-no" title="statement not covered" > return {</span>
|
||||
type: 'shadowsocks',
|
||||
tag,
|
||||
server: serverInfo[0] || '',
|
||||
server_port: Number(serverInfo[1]),
|
||||
method: userInfo[0],
|
||||
password: userInfo[1]
|
||||
}
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > return null</span>
|
||||
}
|
||||
}
|
||||
|
||||
function <span class="fstat-no" title="function not covered" >parseTrojan(l</span>ink: string): NodeConfig | null {
|
||||
<span class="cstat-no" title="statement not covered" > try {</span>
|
||||
const url = <span class="cstat-no" title="statement not covered" >new URL(link)</span>
|
||||
const params = <span class="cstat-no" title="statement not covered" >url.searchParams</span>
|
||||
<span class="cstat-no" title="statement not covered" > return {</span>
|
||||
type: 'trojan',
|
||||
tag: decodeURIComponent(url.hash.slice(1)) || 'Trojan Node',
|
||||
server: url.hostname,
|
||||
server_port: Number(url.port),
|
||||
password: url.username,
|
||||
network: params.get('type') || 'tcp',
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: params.get('sni') || undefined,
|
||||
insecure: params.get('allowInsecure') === '1'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
<span class="cstat-no" title="statement not covered" > return null</span>
|
||||
}
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,853 @@
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>Code coverage report for src/utils/singbox-config.ts</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="stylesheet" href="../../prettify.css" />
|
||||
<link rel="stylesheet" href="../../base.css" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<style type='text/css'>
|
||||
.coverage-summary .sorter {
|
||||
background-image: url(../../sort-arrow-sprite.png);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
<div class='pad1'>
|
||||
<h1><a href="../../index.html">All files</a> / <a href="index.html">src/utils</a> singbox-config.ts</h1>
|
||||
<div class='clearfix'>
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">80% </span>
|
||||
<span class="quiet">Statements</span>
|
||||
<span class='fraction'>28/35</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">38.98% </span>
|
||||
<span class="quiet">Branches</span>
|
||||
<span class='fraction'>23/59</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">100% </span>
|
||||
<span class="quiet">Functions</span>
|
||||
<span class='fraction'>5/5</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class='fl pad1y space-right2'>
|
||||
<span class="strong">82.35% </span>
|
||||
<span class="quiet">Lines</span>
|
||||
<span class='fraction'>28/34</span>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<p class="quiet">
|
||||
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
|
||||
</p>
|
||||
<template id="filterTemplate">
|
||||
<div class="quiet">
|
||||
Filter:
|
||||
<input type="search" id="fileSearch">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class='status-line high'></div>
|
||||
<pre><table class="coverage">
|
||||
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
|
||||
<a name='L2'></a><a href='#L2'>2</a>
|
||||
<a name='L3'></a><a href='#L3'>3</a>
|
||||
<a name='L4'></a><a href='#L4'>4</a>
|
||||
<a name='L5'></a><a href='#L5'>5</a>
|
||||
<a name='L6'></a><a href='#L6'>6</a>
|
||||
<a name='L7'></a><a href='#L7'>7</a>
|
||||
<a name='L8'></a><a href='#L8'>8</a>
|
||||
<a name='L9'></a><a href='#L9'>9</a>
|
||||
<a name='L10'></a><a href='#L10'>10</a>
|
||||
<a name='L11'></a><a href='#L11'>11</a>
|
||||
<a name='L12'></a><a href='#L12'>12</a>
|
||||
<a name='L13'></a><a href='#L13'>13</a>
|
||||
<a name='L14'></a><a href='#L14'>14</a>
|
||||
<a name='L15'></a><a href='#L15'>15</a>
|
||||
<a name='L16'></a><a href='#L16'>16</a>
|
||||
<a name='L17'></a><a href='#L17'>17</a>
|
||||
<a name='L18'></a><a href='#L18'>18</a>
|
||||
<a name='L19'></a><a href='#L19'>19</a>
|
||||
<a name='L20'></a><a href='#L20'>20</a>
|
||||
<a name='L21'></a><a href='#L21'>21</a>
|
||||
<a name='L22'></a><a href='#L22'>22</a>
|
||||
<a name='L23'></a><a href='#L23'>23</a>
|
||||
<a name='L24'></a><a href='#L24'>24</a>
|
||||
<a name='L25'></a><a href='#L25'>25</a>
|
||||
<a name='L26'></a><a href='#L26'>26</a>
|
||||
<a name='L27'></a><a href='#L27'>27</a>
|
||||
<a name='L28'></a><a href='#L28'>28</a>
|
||||
<a name='L29'></a><a href='#L29'>29</a>
|
||||
<a name='L30'></a><a href='#L30'>30</a>
|
||||
<a name='L31'></a><a href='#L31'>31</a>
|
||||
<a name='L32'></a><a href='#L32'>32</a>
|
||||
<a name='L33'></a><a href='#L33'>33</a>
|
||||
<a name='L34'></a><a href='#L34'>34</a>
|
||||
<a name='L35'></a><a href='#L35'>35</a>
|
||||
<a name='L36'></a><a href='#L36'>36</a>
|
||||
<a name='L37'></a><a href='#L37'>37</a>
|
||||
<a name='L38'></a><a href='#L38'>38</a>
|
||||
<a name='L39'></a><a href='#L39'>39</a>
|
||||
<a name='L40'></a><a href='#L40'>40</a>
|
||||
<a name='L41'></a><a href='#L41'>41</a>
|
||||
<a name='L42'></a><a href='#L42'>42</a>
|
||||
<a name='L43'></a><a href='#L43'>43</a>
|
||||
<a name='L44'></a><a href='#L44'>44</a>
|
||||
<a name='L45'></a><a href='#L45'>45</a>
|
||||
<a name='L46'></a><a href='#L46'>46</a>
|
||||
<a name='L47'></a><a href='#L47'>47</a>
|
||||
<a name='L48'></a><a href='#L48'>48</a>
|
||||
<a name='L49'></a><a href='#L49'>49</a>
|
||||
<a name='L50'></a><a href='#L50'>50</a>
|
||||
<a name='L51'></a><a href='#L51'>51</a>
|
||||
<a name='L52'></a><a href='#L52'>52</a>
|
||||
<a name='L53'></a><a href='#L53'>53</a>
|
||||
<a name='L54'></a><a href='#L54'>54</a>
|
||||
<a name='L55'></a><a href='#L55'>55</a>
|
||||
<a name='L56'></a><a href='#L56'>56</a>
|
||||
<a name='L57'></a><a href='#L57'>57</a>
|
||||
<a name='L58'></a><a href='#L58'>58</a>
|
||||
<a name='L59'></a><a href='#L59'>59</a>
|
||||
<a name='L60'></a><a href='#L60'>60</a>
|
||||
<a name='L61'></a><a href='#L61'>61</a>
|
||||
<a name='L62'></a><a href='#L62'>62</a>
|
||||
<a name='L63'></a><a href='#L63'>63</a>
|
||||
<a name='L64'></a><a href='#L64'>64</a>
|
||||
<a name='L65'></a><a href='#L65'>65</a>
|
||||
<a name='L66'></a><a href='#L66'>66</a>
|
||||
<a name='L67'></a><a href='#L67'>67</a>
|
||||
<a name='L68'></a><a href='#L68'>68</a>
|
||||
<a name='L69'></a><a href='#L69'>69</a>
|
||||
<a name='L70'></a><a href='#L70'>70</a>
|
||||
<a name='L71'></a><a href='#L71'>71</a>
|
||||
<a name='L72'></a><a href='#L72'>72</a>
|
||||
<a name='L73'></a><a href='#L73'>73</a>
|
||||
<a name='L74'></a><a href='#L74'>74</a>
|
||||
<a name='L75'></a><a href='#L75'>75</a>
|
||||
<a name='L76'></a><a href='#L76'>76</a>
|
||||
<a name='L77'></a><a href='#L77'>77</a>
|
||||
<a name='L78'></a><a href='#L78'>78</a>
|
||||
<a name='L79'></a><a href='#L79'>79</a>
|
||||
<a name='L80'></a><a href='#L80'>80</a>
|
||||
<a name='L81'></a><a href='#L81'>81</a>
|
||||
<a name='L82'></a><a href='#L82'>82</a>
|
||||
<a name='L83'></a><a href='#L83'>83</a>
|
||||
<a name='L84'></a><a href='#L84'>84</a>
|
||||
<a name='L85'></a><a href='#L85'>85</a>
|
||||
<a name='L86'></a><a href='#L86'>86</a>
|
||||
<a name='L87'></a><a href='#L87'>87</a>
|
||||
<a name='L88'></a><a href='#L88'>88</a>
|
||||
<a name='L89'></a><a href='#L89'>89</a>
|
||||
<a name='L90'></a><a href='#L90'>90</a>
|
||||
<a name='L91'></a><a href='#L91'>91</a>
|
||||
<a name='L92'></a><a href='#L92'>92</a>
|
||||
<a name='L93'></a><a href='#L93'>93</a>
|
||||
<a name='L94'></a><a href='#L94'>94</a>
|
||||
<a name='L95'></a><a href='#L95'>95</a>
|
||||
<a name='L96'></a><a href='#L96'>96</a>
|
||||
<a name='L97'></a><a href='#L97'>97</a>
|
||||
<a name='L98'></a><a href='#L98'>98</a>
|
||||
<a name='L99'></a><a href='#L99'>99</a>
|
||||
<a name='L100'></a><a href='#L100'>100</a>
|
||||
<a name='L101'></a><a href='#L101'>101</a>
|
||||
<a name='L102'></a><a href='#L102'>102</a>
|
||||
<a name='L103'></a><a href='#L103'>103</a>
|
||||
<a name='L104'></a><a href='#L104'>104</a>
|
||||
<a name='L105'></a><a href='#L105'>105</a>
|
||||
<a name='L106'></a><a href='#L106'>106</a>
|
||||
<a name='L107'></a><a href='#L107'>107</a>
|
||||
<a name='L108'></a><a href='#L108'>108</a>
|
||||
<a name='L109'></a><a href='#L109'>109</a>
|
||||
<a name='L110'></a><a href='#L110'>110</a>
|
||||
<a name='L111'></a><a href='#L111'>111</a>
|
||||
<a name='L112'></a><a href='#L112'>112</a>
|
||||
<a name='L113'></a><a href='#L113'>113</a>
|
||||
<a name='L114'></a><a href='#L114'>114</a>
|
||||
<a name='L115'></a><a href='#L115'>115</a>
|
||||
<a name='L116'></a><a href='#L116'>116</a>
|
||||
<a name='L117'></a><a href='#L117'>117</a>
|
||||
<a name='L118'></a><a href='#L118'>118</a>
|
||||
<a name='L119'></a><a href='#L119'>119</a>
|
||||
<a name='L120'></a><a href='#L120'>120</a>
|
||||
<a name='L121'></a><a href='#L121'>121</a>
|
||||
<a name='L122'></a><a href='#L122'>122</a>
|
||||
<a name='L123'></a><a href='#L123'>123</a>
|
||||
<a name='L124'></a><a href='#L124'>124</a>
|
||||
<a name='L125'></a><a href='#L125'>125</a>
|
||||
<a name='L126'></a><a href='#L126'>126</a>
|
||||
<a name='L127'></a><a href='#L127'>127</a>
|
||||
<a name='L128'></a><a href='#L128'>128</a>
|
||||
<a name='L129'></a><a href='#L129'>129</a>
|
||||
<a name='L130'></a><a href='#L130'>130</a>
|
||||
<a name='L131'></a><a href='#L131'>131</a>
|
||||
<a name='L132'></a><a href='#L132'>132</a>
|
||||
<a name='L133'></a><a href='#L133'>133</a>
|
||||
<a name='L134'></a><a href='#L134'>134</a>
|
||||
<a name='L135'></a><a href='#L135'>135</a>
|
||||
<a name='L136'></a><a href='#L136'>136</a>
|
||||
<a name='L137'></a><a href='#L137'>137</a>
|
||||
<a name='L138'></a><a href='#L138'>138</a>
|
||||
<a name='L139'></a><a href='#L139'>139</a>
|
||||
<a name='L140'></a><a href='#L140'>140</a>
|
||||
<a name='L141'></a><a href='#L141'>141</a>
|
||||
<a name='L142'></a><a href='#L142'>142</a>
|
||||
<a name='L143'></a><a href='#L143'>143</a>
|
||||
<a name='L144'></a><a href='#L144'>144</a>
|
||||
<a name='L145'></a><a href='#L145'>145</a>
|
||||
<a name='L146'></a><a href='#L146'>146</a>
|
||||
<a name='L147'></a><a href='#L147'>147</a>
|
||||
<a name='L148'></a><a href='#L148'>148</a>
|
||||
<a name='L149'></a><a href='#L149'>149</a>
|
||||
<a name='L150'></a><a href='#L150'>150</a>
|
||||
<a name='L151'></a><a href='#L151'>151</a>
|
||||
<a name='L152'></a><a href='#L152'>152</a>
|
||||
<a name='L153'></a><a href='#L153'>153</a>
|
||||
<a name='L154'></a><a href='#L154'>154</a>
|
||||
<a name='L155'></a><a href='#L155'>155</a>
|
||||
<a name='L156'></a><a href='#L156'>156</a>
|
||||
<a name='L157'></a><a href='#L157'>157</a>
|
||||
<a name='L158'></a><a href='#L158'>158</a>
|
||||
<a name='L159'></a><a href='#L159'>159</a>
|
||||
<a name='L160'></a><a href='#L160'>160</a>
|
||||
<a name='L161'></a><a href='#L161'>161</a>
|
||||
<a name='L162'></a><a href='#L162'>162</a>
|
||||
<a name='L163'></a><a href='#L163'>163</a>
|
||||
<a name='L164'></a><a href='#L164'>164</a>
|
||||
<a name='L165'></a><a href='#L165'>165</a>
|
||||
<a name='L166'></a><a href='#L166'>166</a>
|
||||
<a name='L167'></a><a href='#L167'>167</a>
|
||||
<a name='L168'></a><a href='#L168'>168</a>
|
||||
<a name='L169'></a><a href='#L169'>169</a>
|
||||
<a name='L170'></a><a href='#L170'>170</a>
|
||||
<a name='L171'></a><a href='#L171'>171</a>
|
||||
<a name='L172'></a><a href='#L172'>172</a>
|
||||
<a name='L173'></a><a href='#L173'>173</a>
|
||||
<a name='L174'></a><a href='#L174'>174</a>
|
||||
<a name='L175'></a><a href='#L175'>175</a>
|
||||
<a name='L176'></a><a href='#L176'>176</a>
|
||||
<a name='L177'></a><a href='#L177'>177</a>
|
||||
<a name='L178'></a><a href='#L178'>178</a>
|
||||
<a name='L179'></a><a href='#L179'>179</a>
|
||||
<a name='L180'></a><a href='#L180'>180</a>
|
||||
<a name='L181'></a><a href='#L181'>181</a>
|
||||
<a name='L182'></a><a href='#L182'>182</a>
|
||||
<a name='L183'></a><a href='#L183'>183</a>
|
||||
<a name='L184'></a><a href='#L184'>184</a>
|
||||
<a name='L185'></a><a href='#L185'>185</a>
|
||||
<a name='L186'></a><a href='#L186'>186</a>
|
||||
<a name='L187'></a><a href='#L187'>187</a>
|
||||
<a name='L188'></a><a href='#L188'>188</a>
|
||||
<a name='L189'></a><a href='#L189'>189</a>
|
||||
<a name='L190'></a><a href='#L190'>190</a>
|
||||
<a name='L191'></a><a href='#L191'>191</a>
|
||||
<a name='L192'></a><a href='#L192'>192</a>
|
||||
<a name='L193'></a><a href='#L193'>193</a>
|
||||
<a name='L194'></a><a href='#L194'>194</a>
|
||||
<a name='L195'></a><a href='#L195'>195</a>
|
||||
<a name='L196'></a><a href='#L196'>196</a>
|
||||
<a name='L197'></a><a href='#L197'>197</a>
|
||||
<a name='L198'></a><a href='#L198'>198</a>
|
||||
<a name='L199'></a><a href='#L199'>199</a>
|
||||
<a name='L200'></a><a href='#L200'>200</a>
|
||||
<a name='L201'></a><a href='#L201'>201</a>
|
||||
<a name='L202'></a><a href='#L202'>202</a>
|
||||
<a name='L203'></a><a href='#L203'>203</a>
|
||||
<a name='L204'></a><a href='#L204'>204</a>
|
||||
<a name='L205'></a><a href='#L205'>205</a>
|
||||
<a name='L206'></a><a href='#L206'>206</a>
|
||||
<a name='L207'></a><a href='#L207'>207</a>
|
||||
<a name='L208'></a><a href='#L208'>208</a>
|
||||
<a name='L209'></a><a href='#L209'>209</a>
|
||||
<a name='L210'></a><a href='#L210'>210</a>
|
||||
<a name='L211'></a><a href='#L211'>211</a>
|
||||
<a name='L212'></a><a href='#L212'>212</a>
|
||||
<a name='L213'></a><a href='#L213'>213</a>
|
||||
<a name='L214'></a><a href='#L214'>214</a>
|
||||
<a name='L215'></a><a href='#L215'>215</a>
|
||||
<a name='L216'></a><a href='#L216'>216</a>
|
||||
<a name='L217'></a><a href='#L217'>217</a>
|
||||
<a name='L218'></a><a href='#L218'>218</a>
|
||||
<a name='L219'></a><a href='#L219'>219</a>
|
||||
<a name='L220'></a><a href='#L220'>220</a>
|
||||
<a name='L221'></a><a href='#L221'>221</a>
|
||||
<a name='L222'></a><a href='#L222'>222</a>
|
||||
<a name='L223'></a><a href='#L223'>223</a>
|
||||
<a name='L224'></a><a href='#L224'>224</a>
|
||||
<a name='L225'></a><a href='#L225'>225</a>
|
||||
<a name='L226'></a><a href='#L226'>226</a>
|
||||
<a name='L227'></a><a href='#L227'>227</a>
|
||||
<a name='L228'></a><a href='#L228'>228</a>
|
||||
<a name='L229'></a><a href='#L229'>229</a>
|
||||
<a name='L230'></a><a href='#L230'>230</a>
|
||||
<a name='L231'></a><a href='#L231'>231</a>
|
||||
<a name='L232'></a><a href='#L232'>232</a>
|
||||
<a name='L233'></a><a href='#L233'>233</a>
|
||||
<a name='L234'></a><a href='#L234'>234</a>
|
||||
<a name='L235'></a><a href='#L235'>235</a>
|
||||
<a name='L236'></a><a href='#L236'>236</a>
|
||||
<a name='L237'></a><a href='#L237'>237</a>
|
||||
<a name='L238'></a><a href='#L238'>238</a>
|
||||
<a name='L239'></a><a href='#L239'>239</a>
|
||||
<a name='L240'></a><a href='#L240'>240</a>
|
||||
<a name='L241'></a><a href='#L241'>241</a>
|
||||
<a name='L242'></a><a href='#L242'>242</a>
|
||||
<a name='L243'></a><a href='#L243'>243</a>
|
||||
<a name='L244'></a><a href='#L244'>244</a>
|
||||
<a name='L245'></a><a href='#L245'>245</a>
|
||||
<a name='L246'></a><a href='#L246'>246</a>
|
||||
<a name='L247'></a><a href='#L247'>247</a>
|
||||
<a name='L248'></a><a href='#L248'>248</a>
|
||||
<a name='L249'></a><a href='#L249'>249</a>
|
||||
<a name='L250'></a><a href='#L250'>250</a>
|
||||
<a name='L251'></a><a href='#L251'>251</a>
|
||||
<a name='L252'></a><a href='#L252'>252</a>
|
||||
<a name='L253'></a><a href='#L253'>253</a>
|
||||
<a name='L254'></a><a href='#L254'>254</a>
|
||||
<a name='L255'></a><a href='#L255'>255</a>
|
||||
<a name='L256'></a><a href='#L256'>256</a>
|
||||
<a name='L257'></a><a href='#L257'>257</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">1x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-yes">3x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-no"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-yes">4x</span>
|
||||
<span class="cline-any cline-neutral"> </span>
|
||||
<span class="cline-any cline-neutral"> </span></td><td class="text"><pre class="prettyprint lang-js">import type { Game } from '@/types'
|
||||
import type { NodeConfig } from '@/utils/protocol'
|
||||
|
||||
interface SingboxConfig {
|
||||
log: {
|
||||
level: string
|
||||
timestamp: boolean
|
||||
}
|
||||
dns: {
|
||||
servers: any[]
|
||||
rules: any[]
|
||||
final?: string
|
||||
}
|
||||
inbounds: any[]
|
||||
outbounds: any[]
|
||||
route: {
|
||||
rules: any[]
|
||||
auto_detect_interface: boolean
|
||||
final?: string
|
||||
}
|
||||
experimental?: {
|
||||
cache_file?: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSingboxConfig(game: Game, node: NodeConfig): string {
|
||||
const isRoutingMode = game.proxyMode === 'routing'
|
||||
const processes = normalizeProcessNames(Array.isArray(game.processName) ? game.processName : <span class="branch-1 cbranch-no" title="branch not covered" >[game.processName])</span>
|
||||
const googleDomains = ['google.com', 'youtube.com', 'gstatic.com', 'googleapis.com', 'ytimg.com', 'ggpht.com']
|
||||
const googleKeywords = ['google', 'youtube']
|
||||
|
||||
// Basic Config Structure
|
||||
const config: SingboxConfig = {
|
||||
log: {
|
||||
level: 'info',
|
||||
timestamp: true
|
||||
},
|
||||
dns: {
|
||||
servers: [
|
||||
{ tag: 'google', address: '8.8.8.8', detour: 'proxy', strategy: 'ipv4_only' },
|
||||
{ tag: 'cloudflare', address: '1.1.1.1', detour: 'proxy', strategy: 'ipv4_only' },
|
||||
{ tag: 'local', address: '223.5.5.5', detour: 'direct', strategy: 'ipv4_only' },
|
||||
{ tag: 'block', address: 'rcode://success' }
|
||||
],
|
||||
rules: isRoutingMode ? [] : [
|
||||
{ outbound: 'any', server: 'local' }
|
||||
],
|
||||
final: isRoutingMode ? 'google' : 'local'
|
||||
},
|
||||
inbounds: [
|
||||
{
|
||||
type: 'tun',
|
||||
tag: 'tun-in',
|
||||
interface_name: 'singbox-tun',
|
||||
inet4_address: '172.19.0.1/30',
|
||||
mtu: 1280,
|
||||
auto_route: true,
|
||||
strict_route: false, // Set to true if you want to prevent leaks absolutely, but false is safer for compatibility
|
||||
stack: 'system', // 'gvisor' or 'system' or 'mixed'
|
||||
sniff: true
|
||||
}
|
||||
],
|
||||
outbounds: [
|
||||
{
|
||||
type: 'selector',
|
||||
tag: 'proxy',
|
||||
outbounds: ['node-out', 'direct'],
|
||||
default: 'node-out'
|
||||
},
|
||||
{
|
||||
type: 'direct',
|
||||
tag: 'direct'
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
tag: 'block'
|
||||
},
|
||||
{
|
||||
type: 'dns',
|
||||
tag: 'dns-out'
|
||||
}
|
||||
],
|
||||
route: {
|
||||
rules: [
|
||||
{ protocol: 'dns', outbound: 'dns-out' }
|
||||
],
|
||||
auto_detect_interface: true,
|
||||
final: isRoutingMode ? 'proxy' : 'direct'
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Convert Node to Outbound
|
||||
const nodeOutbound = convertNodeToOutbound(node)
|
||||
config.outbounds.push(nodeOutbound)
|
||||
|
||||
// 2. Configure DNS Rules
|
||||
// If accelerating game, game DNS should go through remote DNS
|
||||
config.dns.rules.unshift({
|
||||
domain_suffix: googleDomains,
|
||||
domain_keyword: googleKeywords,
|
||||
server: 'google'
|
||||
})
|
||||
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (processes.length > 0) {
|
||||
config.dns.rules.unshift({
|
||||
process_name: processes,
|
||||
server: 'google'
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Configure Route Rules
|
||||
config.route.rules.push({
|
||||
domain_suffix: googleDomains,
|
||||
domain_keyword: googleKeywords,
|
||||
outbound: 'proxy'
|
||||
})
|
||||
|
||||
if (isRoutingMode) {
|
||||
// Handle "Bypass Mainland China" or "Global" based on routing_rules
|
||||
const routingRules = game.routingRules || []
|
||||
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (routingRules.includes('bypass_cn')) {
|
||||
// Bypass Mainland China Rules
|
||||
<span class="cstat-no" title="statement not covered" > config.route.rules.push({</span>
|
||||
ip_is_private: true,
|
||||
outbound: 'direct'
|
||||
})
|
||||
<span class="cstat-no" title="statement not covered" > config.route.rules.push({</span>
|
||||
geosite: ['cn'],
|
||||
outbound: 'direct'
|
||||
})
|
||||
// Default final is 'proxy' (set in config.route.final)
|
||||
} else {
|
||||
// Global Mode (or default routing)
|
||||
// Default final is 'proxy'
|
||||
}
|
||||
} else {
|
||||
// Process Mode: Only Game Process -> Proxy, Default -> Direct
|
||||
<span class="missing-if-branch" title="else path not taken" >E</span>if (processes.length > 0) {
|
||||
config.route.rules.push({
|
||||
process_name: processes,
|
||||
outbound: 'proxy'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(config, null, 2)
|
||||
}
|
||||
|
||||
function convertNodeToOutbound(node: NodeConfig): any {
|
||||
const base = {
|
||||
tag: 'node-out',
|
||||
server: node.server,
|
||||
server_port: node.server_port
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
<span class="branch-0 cbranch-no" title="branch not covered" > case 'ss':</span>
|
||||
<span class="branch-1 cbranch-no" title="branch not covered" > case 'shadowsocks':</span>
|
||||
<span class="cstat-no" title="statement not covered" > return {</span>
|
||||
...base,
|
||||
type: 'shadowsocks',
|
||||
method: node.method,
|
||||
password: node.password,
|
||||
plugin: node.plugin,
|
||||
plugin_opts: node.plugin_opts
|
||||
}
|
||||
case 'vmess':
|
||||
return {
|
||||
...base,
|
||||
type: 'vmess',
|
||||
uuid: node.uuid,
|
||||
security: node.security || <span class="branch-1 cbranch-no" title="branch not covered" >'auto',</span>
|
||||
alter_id: 0,
|
||||
transport: node.network ? {
|
||||
type: node.network,
|
||||
path: node.path,
|
||||
headers: node.host ? <span class="branch-0 cbranch-no" title="branch not covered" >{ Host: node.host } : u</span>ndefined
|
||||
} : <span class="branch-1 cbranch-no" title="branch not covered" >undefined,</span>
|
||||
tls: node.tls?.enabled ? {
|
||||
enabled: true,
|
||||
server_name: node.tls.server_name || <span class="branch-1 cbranch-no" title="branch not covered" >node.host,</span>
|
||||
insecure: node.tls.insecure,
|
||||
utls: node.tls.utls?.enabled ? <span class="branch-0 cbranch-no" title="branch not covered" >{</span>
|
||||
enabled: true,
|
||||
fingerprint: node.tls.utls.fingerprint
|
||||
} : undefined
|
||||
} : <span class="branch-1 cbranch-no" title="branch not covered" >undefined</span>
|
||||
}
|
||||
<span class="branch-3 cbranch-no" title="branch not covered" > case 'vless':</span>
|
||||
<span class="cstat-no" title="statement not covered" > return {</span>
|
||||
...base,
|
||||
type: 'vless',
|
||||
uuid: node.uuid,
|
||||
flow: node.flow,
|
||||
packet_encoding: node.packet_encoding,
|
||||
transport: node.network ? {
|
||||
type: node.network,
|
||||
path: node.path,
|
||||
headers: node.host ? { Host: node.host } : undefined
|
||||
} : undefined,
|
||||
tls: node.tls?.enabled ? {
|
||||
enabled: true,
|
||||
server_name: node.tls.server_name || node.host,
|
||||
insecure: node.tls.insecure,
|
||||
utls: node.tls.utls?.enabled ? {
|
||||
enabled: true,
|
||||
fingerprint: node.tls.utls.fingerprint
|
||||
} : undefined
|
||||
} : undefined
|
||||
}
|
||||
<span class="branch-4 cbranch-no" title="branch not covered" > case 'trojan':</span>
|
||||
<span class="cstat-no" title="statement not covered" > return {</span>
|
||||
...base,
|
||||
type: 'trojan',
|
||||
password: node.password,
|
||||
transport: node.network ? {
|
||||
type: node.network,
|
||||
path: node.path,
|
||||
headers: node.host ? { Host: node.host } : undefined
|
||||
} : undefined,
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: node.tls?.server_name || node.host || node.server,
|
||||
insecure: node.tls?.insecure,
|
||||
utls: node.tls?.utls?.enabled ? {
|
||||
enabled: true,
|
||||
fingerprint: node.tls.utls.fingerprint
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
<span class="branch-5 cbranch-no" title="branch not covered" > default:</span>
|
||||
// Fallback or Error
|
||||
<span class="cstat-no" title="statement not covered" > return {</span>
|
||||
...base,
|
||||
type: 'socks', // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProcessNames(processes: string[]): string[] {
|
||||
const items = processes
|
||||
.map(p => String(p ?? <span class="branch-1 cbranch-no" title="branch not covered" >'')</span>.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const set = new Set<string>()
|
||||
items.forEach(p => {
|
||||
set.add(p)
|
||||
const lower = p.toLowerCase()
|
||||
<span class="missing-if-branch" title="if path not taken" >I</span>if (lower !== p) <span class="cstat-no" title="statement not covered" >set.add(lower)</span>
|
||||
})
|
||||
|
||||
return Array.from(set)
|
||||
}
|
||||
</pre></td></tr></table></pre>
|
||||
|
||||
<div class='push'></div><!-- for sticky footer -->
|
||||
</div><!-- /wrapper -->
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
|
||||
at 2026-02-10T01:17:35.407Z
|
||||
</div>
|
||||
<script src="../../prettify.js"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
prettyPrint();
|
||||
};
|
||||
</script>
|
||||
<script src="../../sorter.js"></script>
|
||||
<script src="../../block-navigation.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11859
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,56 @@
|
||||
"use strict";
|
||||
const electron = require("electron");
|
||||
const ipcListenerMap = /* @__PURE__ */ new Map();
|
||||
electron.contextBridge.exposeInMainWorld("electron", {
|
||||
minimize: () => electron.ipcRenderer.invoke("window-minimize"),
|
||||
maximize: () => electron.ipcRenderer.invoke("window-maximize"),
|
||||
close: () => electron.ipcRenderer.invoke("window-close"),
|
||||
pickImage: () => electron.ipcRenderer.invoke("dialog:pick-image"),
|
||||
pickProcess: () => electron.ipcRenderer.invoke("dialog:pick-process"),
|
||||
pickProcessFolder: (maxDepth) => electron.ipcRenderer.invoke("dialog:pick-process-folder", maxDepth),
|
||||
on: (channel, callback) => {
|
||||
const wrapped = (_event, ...args) => callback(...args);
|
||||
const channelMap = ipcListenerMap.get(channel) || /* @__PURE__ */ new Map();
|
||||
channelMap.set(callback, wrapped);
|
||||
ipcListenerMap.set(channel, channelMap);
|
||||
electron.ipcRenderer.on(channel, wrapped);
|
||||
},
|
||||
off: (channel, callback) => {
|
||||
const channelMap = ipcListenerMap.get(channel);
|
||||
const wrapped = channelMap?.get(callback);
|
||||
if (!wrapped) return;
|
||||
electron.ipcRenderer.removeListener(channel, wrapped);
|
||||
channelMap?.delete(callback);
|
||||
}
|
||||
});
|
||||
electron.contextBridge.exposeInMainWorld("singbox", {
|
||||
start: (config) => electron.ipcRenderer.invoke("singbox-start", config),
|
||||
stop: () => electron.ipcRenderer.invoke("singbox-stop"),
|
||||
restart: (config) => electron.ipcRenderer.invoke("singbox-restart", config)
|
||||
});
|
||||
electron.contextBridge.exposeInMainWorld("system", {
|
||||
scanProcesses: () => electron.ipcRenderer.invoke("process-scan"),
|
||||
getProcessTree: () => electron.ipcRenderer.invoke("process-tree"),
|
||||
ping: (host) => electron.ipcRenderer.invoke("system:ping", host),
|
||||
tcpPing: (host, port) => electron.ipcRenderer.invoke("system:tcp-ping", host, port)
|
||||
});
|
||||
electron.contextBridge.exposeInMainWorld("proxyMonitor", {
|
||||
start: (gameId, processNames) => electron.ipcRenderer.invoke("proxy-monitor:start", gameId, processNames),
|
||||
stop: () => electron.ipcRenderer.invoke("proxy-monitor:stop")
|
||||
});
|
||||
electron.contextBridge.exposeInMainWorld("nodes", {
|
||||
getAll: () => electron.ipcRenderer.invoke("nodes:get-all"),
|
||||
save: (node) => electron.ipcRenderer.invoke("nodes:save", node),
|
||||
delete: (id) => electron.ipcRenderer.invoke("nodes:delete", id),
|
||||
import: (nodes) => electron.ipcRenderer.invoke("nodes:import", nodes)
|
||||
});
|
||||
electron.contextBridge.exposeInMainWorld("games", {
|
||||
getAll: () => electron.ipcRenderer.invoke("games:get-all"),
|
||||
save: (game) => electron.ipcRenderer.invoke("games:save", game),
|
||||
delete: (id) => electron.ipcRenderer.invoke("games:delete", id)
|
||||
});
|
||||
electron.contextBridge.exposeInMainWorld("categories", {
|
||||
getAll: () => electron.ipcRenderer.invoke("categories:get-all"),
|
||||
save: (category) => electron.ipcRenderer.invoke("categories:save", category),
|
||||
delete: (id) => electron.ipcRenderer.invoke("categories:delete", id)
|
||||
});
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
# LagZero API Documentation
|
||||
|
||||
## IPC Interfaces
|
||||
|
||||
The LagZero backend exposes several IPC interfaces for the frontend to interact with the core system.
|
||||
|
||||
### Categories API (`window.categories`)
|
||||
|
||||
- `getAll()`: Promise<Category[]>
|
||||
- Returns all configured categories.
|
||||
- `save(category: Category)`: Promise<Category[]>
|
||||
- Creates or updates a category.
|
||||
- `delete(id: string)`: Promise<Category[]>
|
||||
- Deletes a category by ID.
|
||||
- `match(name: string, processes: string[])`: Promise<string | null>
|
||||
- Attempts to auto-classify a game based on its name and process list using configured rules.
|
||||
|
||||
### Profiles API (`window.profiles`)
|
||||
|
||||
- `getAll()`: Promise<ConfigProfile[]>
|
||||
- Returns all configuration profiles.
|
||||
- `save(profile: ConfigProfile)`: Promise<ConfigProfile[]>
|
||||
- Creates or updates a configuration profile.
|
||||
- `delete(id: string)`: Promise<ConfigProfile[]>
|
||||
- Deletes a profile by ID.
|
||||
|
||||
### Proxy Monitor API (`window.proxyMonitor`)
|
||||
|
||||
- `start(gameId: string, processNames: string[])`: Promise<void>
|
||||
- Starts the intelligent proxy monitor for a specific game.
|
||||
- Enables chain proxy detection (child process monitoring).
|
||||
- `stop()`: Promise<void>
|
||||
- Stops the current monitoring session.
|
||||
|
||||
### System API (`window.system`)
|
||||
|
||||
- `scanProcesses()`: Promise<string[]>
|
||||
- Returns a list of currently running process names.
|
||||
- `getProcessTree()`: Promise<ProcessInfo[]>
|
||||
- Returns the full process tree (Windows only).
|
||||
|
||||
## Data Models
|
||||
|
||||
### Category
|
||||
```typescript
|
||||
interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId?: string;
|
||||
rules?: string[]; // Regex patterns for auto-classification
|
||||
icon?: string;
|
||||
order?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### ConfigProfile
|
||||
```typescript
|
||||
interface ConfigProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
rules: ConfigRule[];
|
||||
chainProxy: boolean;
|
||||
}
|
||||
|
||||
interface ConfigRule {
|
||||
type: 'process' | 'domain' | 'ip';
|
||||
value: string[];
|
||||
outbound: 'proxy' | 'direct' | 'block';
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,44 @@
|
||||
# Dashboard UI Upgrade Report
|
||||
|
||||
## 1. Overview
|
||||
This report documents the full UI upgrade of the Dashboard component (`src/views/Dashboard.vue`) and the Node Selector (`src/components/NodeSelector.vue`). The goal was to modernize the interface with a card-based layout, dynamic animations, real-time metrics, and full responsiveness.
|
||||
|
||||
## 2. Accessibility (Lighthouse Simulation)
|
||||
**Target Score: ≥90**
|
||||
|
||||
| Category | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| **Contrast** | ✅ Pass | Text-on-surface colors (`text-on-surface`, `text-on-surface-variant`) ensure high contrast against `bg-surface` and `bg-surface-panel`. |
|
||||
| **Touch Targets** | ✅ Pass | All interactive elements (buttons, inputs, cards) have min-height/width of 44px (e.g., `h-14`, `p-4`). |
|
||||
| **Labels** | ✅ Pass | Inputs have associated labels or `aria-label`/`placeholder`. |
|
||||
| **Structure** | ✅ Pass | Semantic HTML used (`button`, `input`, `h1`, `h2`). |
|
||||
| **Responsive** | ✅ Pass | Layout adapts from mobile (stack) to desktop (2-column). No horizontal scrolling. |
|
||||
|
||||
## 3. UI Test Cases (Node Selector Interaction)
|
||||
|
||||
| ID | Test Case | Preconditions | Steps | Expected Result |
|
||||
|----|-----------|---------------|-------|-----------------|
|
||||
| **TC-001** | **Node Selection** | Dashboard loaded, nodes available | 1. Click on a node card in the selector. | 1. Card highlights with primary border and gradient.<br>2. Checkmark icon animates in.<br>3. `selectedNode` updates in state. |
|
||||
| **TC-002** | **Hover Effect** | Dashboard loaded | 1. Hover over any node card. | 1. Card lifts slightly (`-translate-y-1`).<br>2. Shadow increases (`shadow-lg`).<br>3. Border color highlights. |
|
||||
| **TC-003** | **Search Filtering** | Dashboard loaded, nodes with different names | 1. Type "HK" in the search box. | 1. Node list filters to show only nodes containing "HK".<br>2. "No nodes found" shown if no match. |
|
||||
| **TC-004** | **Type Filtering** | Dashboard loaded, nodes of type VMess and Shadowsocks | 1. Click "VMESS" filter button. | 1. Only VMess nodes are displayed.<br>2. Button becomes active (primary color). |
|
||||
| **TC-005** | **Sorting by Latency** | Dashboard loaded | 1. Select "Latency" from sort dropdown. | 1. Nodes reorder with lowest latency first. |
|
||||
| **TC-006** | **Responsive Layout (Mobile)** | Viewport width 375px | 1. Observe Dashboard layout. | 1. Layout is single column.<br>2. Status panel is at top.<br>3. Node selector follows below.<br>4. No horizontal scroll. |
|
||||
| **TC-007** | **Start Acceleration** | Game selected, Node selected | 1. Click "Start Acceleration" button. | 1. Button changes to "Stop".<br>2. Status indicator pulses green.<br>3. Duration counter starts. |
|
||||
| **TC-008** | **Stop Acceleration** | Acceleration running | 1. Click "Stop Acceleration" button. | 1. Button changes to "Start".<br>2. Status indicator becomes grey.<br>3. Duration resets. |
|
||||
| **TC-009** | **Mode Switching** | Dashboard loaded | 1. Click "Routing Mode" radio card. | 1. Selection indicator moves.<br>2. Card border highlights.<br>3. `proxyMode` updates to 'routing'. |
|
||||
| **TC-010** | **Empty State** | No game selected | 1. Navigate to Dashboard without selecting game. | 1. "No Game Selected" placeholder shown.<br>2. "Go to Library" button is visible and functional. |
|
||||
|
||||
## 4. Implementation Details
|
||||
- **Tech Stack**: Vue 3 (Composition API), Tailwind CSS (UnoCSS), Pinia.
|
||||
- **Components**:
|
||||
- `Dashboard.vue`: Main layout controller.
|
||||
- `NodeSelector.vue`: Reusable component for node management.
|
||||
- **Key Features**:
|
||||
- **Glassmorphism**: `backdrop-blur` and semi-transparent backgrounds.
|
||||
- **Micro-interactions**: Hover states, active states, pulse animations.
|
||||
- **Data Viz**: Integrated latency chart and real-time metric cards.
|
||||
|
||||
## 5. Visual Comparison (Description)
|
||||
- **Before**: Simple text list for nodes, basic buttons, flat layout.
|
||||
- **After**: Rich card grid for nodes with flags/stats, gradient buttons, glowing effects, organized dashboard layout with clear hierarchy.
|
||||
@@ -0,0 +1,30 @@
|
||||
# LagZero User Manual
|
||||
|
||||
## Category Management
|
||||
|
||||
LagZero allows you to organize your games and applications into custom categories.
|
||||
|
||||
1. **Accessing Categories**: Click the "Manage Categories" button (folder icon) in the Game Library view.
|
||||
2. **Adding a Category**: Enter a name in the input field and click "Add".
|
||||
3. **Editing**: Double-click a category name to rename it.
|
||||
4. **Reordering**: Drag and drop categories using the handle icon to change their display order.
|
||||
5. **Deleting**: Click the trash icon to remove a category.
|
||||
|
||||
## Advanced Configuration
|
||||
|
||||
For games requiring specific network settings, use the Advanced Config Editor.
|
||||
|
||||
1. **Editing Game Config**: Click the "Edit" (pencil) icon on any game card.
|
||||
2. **Process Management**:
|
||||
- Add multiple process names (e.g., launcher and game executable).
|
||||
- The system supports multi-select and dynamic addition of processes.
|
||||
3. **Chain Proxy**:
|
||||
- Enable "Chain Proxy Detection" to automatically proxy child processes started by the game.
|
||||
- This is useful for games with launchers that spawn separate game clients.
|
||||
|
||||
## Intelligent Proxy Monitor
|
||||
|
||||
When a game is running with acceleration enabled:
|
||||
- The **Proxy Monitor** panel in the Dashboard shows real-time status.
|
||||
- If "Chain Proxy" is enabled, any new child processes detected by the system will be listed in the monitor panel.
|
||||
- These processes are automatically added to the proxy rules without restarting the game.
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { DatabaseManager } from '../db'
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
parentId?: string
|
||||
rules?: string[]
|
||||
icon?: string
|
||||
order?: number
|
||||
}
|
||||
|
||||
export class CategoryManager {
|
||||
private db: DatabaseManager
|
||||
private categoriesCache: Category[] = []
|
||||
|
||||
constructor(db: DatabaseManager) {
|
||||
this.db = db
|
||||
this.refreshCache()
|
||||
this.registerIPC()
|
||||
}
|
||||
|
||||
private async refreshCache() {
|
||||
try {
|
||||
this.categoriesCache = await this.db.getAllCategories()
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh categories cache:', e)
|
||||
}
|
||||
}
|
||||
|
||||
public async getAll() {
|
||||
this.categoriesCache = await this.db.getAllCategories()
|
||||
return this.categoriesCache
|
||||
}
|
||||
|
||||
public async save(category: Category) {
|
||||
const result = await this.db.saveCategory(category)
|
||||
this.categoriesCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
public async delete(id: string) {
|
||||
const result = await this.db.deleteCategory(id)
|
||||
this.categoriesCache = result
|
||||
return result
|
||||
}
|
||||
|
||||
public matchCategory(gameName: string, processNames: string | string[]): string | null {
|
||||
// Use cache for synchronous matching logic if needed, or just use current state
|
||||
const categories = this.categoriesCache
|
||||
const pNames = Array.isArray(processNames) ? processNames : [processNames]
|
||||
|
||||
for (const cat of categories) {
|
||||
if (cat.rules && cat.rules.length > 0) {
|
||||
for (const rule of cat.rules) {
|
||||
try {
|
||||
const regex = new RegExp(rule, 'i')
|
||||
if (regex.test(gameName)) return cat.id
|
||||
for (const pName of pNames) {
|
||||
if (regex.test(pName)) return cat.id
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore invalid regex
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private registerIPC() {
|
||||
ipcMain.handle('categories:get-all', () => this.getAll())
|
||||
ipcMain.handle('categories:save', (_, category: Category) => this.save(category))
|
||||
ipcMain.handle('categories:delete', (_, id: string) => this.delete(id))
|
||||
ipcMain.handle('categories:match', (_, name: string, processes: string[]) => this.matchCategory(name, processes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'node:path'
|
||||
|
||||
export interface StoreOptions<T> {
|
||||
name: string
|
||||
defaults: T
|
||||
cwd: string
|
||||
backupCount?: number
|
||||
}
|
||||
|
||||
export class JsonStore<T> {
|
||||
private filePath: string
|
||||
private backupDir: string
|
||||
private data: T
|
||||
private options: StoreOptions<T>
|
||||
|
||||
constructor(options: StoreOptions<T>) {
|
||||
this.options = { backupCount: 5, ...options }
|
||||
this.filePath = path.join(this.options.cwd, `${this.options.name}.json`)
|
||||
this.backupDir = path.join(this.options.cwd, 'backups', this.options.name)
|
||||
this.data = this.options.defaults
|
||||
this.init()
|
||||
}
|
||||
|
||||
private init() {
|
||||
try {
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
this.data = fs.readJsonSync(this.filePath)
|
||||
} else {
|
||||
this.save()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to load store ${this.options.name}:`, e)
|
||||
// Attempt to load latest backup if main file is corrupted?
|
||||
}
|
||||
}
|
||||
|
||||
public get(): T {
|
||||
return this.data
|
||||
}
|
||||
|
||||
public set(data: T) {
|
||||
this.data = data
|
||||
this.save()
|
||||
}
|
||||
|
||||
private async save() {
|
||||
try {
|
||||
await this.backup()
|
||||
await fs.writeJson(this.filePath, this.data, { spaces: 2 })
|
||||
} catch (e) {
|
||||
console.error(`Failed to save store ${this.options.name}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
private async backup() {
|
||||
if (!this.options.backupCount || this.options.backupCount <= 0) return
|
||||
|
||||
try {
|
||||
await fs.ensureDir(this.backupDir)
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const backupPath = path.join(this.backupDir, `${this.options.name}-${timestamp}.json`)
|
||||
|
||||
if (fs.existsSync(this.filePath)) {
|
||||
await fs.copy(this.filePath, backupPath)
|
||||
}
|
||||
|
||||
// Cleanup old backups
|
||||
const files = await fs.readdir(this.backupDir)
|
||||
const backups = files
|
||||
.filter(f => f.startsWith(this.options.name) && f.endsWith('.json'))
|
||||
.map(f => path.join(this.backupDir, f))
|
||||
.sort((a, b) => fs.statSync(b).mtime.getTime() - fs.statSync(a).mtime.getTime())
|
||||
|
||||
if (backups.length > this.options.backupCount) {
|
||||
const toDelete = backups.slice(this.options.backupCount)
|
||||
await Promise.all(toDelete.map(f => fs.remove(f)))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Backup failed for ${this.options.name}:`, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
import DatabaseConstructor from 'better-sqlite3'
|
||||
import { Kysely, SqliteDialect } from 'kysely'
|
||||
import { app } from 'electron'
|
||||
import path from 'path'
|
||||
import fs from 'fs-extra'
|
||||
import { Database } from './schema'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
export class DatabaseManager {
|
||||
private db: Kysely<Database>
|
||||
private sqlite: DatabaseConstructor.Database
|
||||
private otherCategoryId: string | null | undefined
|
||||
|
||||
constructor() {
|
||||
const userDataPath = app.getPath('userData')
|
||||
const dbPath = path.join(userDataPath, 'lagzero.db')
|
||||
|
||||
fs.ensureDirSync(userDataPath)
|
||||
|
||||
this.sqlite = new DatabaseConstructor(dbPath)
|
||||
this.db = new Kysely<Database>({
|
||||
dialect: new SqliteDialect({
|
||||
database: this.sqlite
|
||||
})
|
||||
})
|
||||
|
||||
this.initSchema()
|
||||
}
|
||||
|
||||
private isIconUrl(value: string) {
|
||||
const v = value.trim()
|
||||
return (
|
||||
v.startsWith('http://') ||
|
||||
v.startsWith('https://') ||
|
||||
v.startsWith('file://') ||
|
||||
v.startsWith('data:')
|
||||
)
|
||||
}
|
||||
|
||||
private normalizeNodeType(type: unknown): string {
|
||||
const t = String(type ?? '').trim().toLowerCase()
|
||||
if (t === 'ss' || t === 'shadowsocks') return 'shadowsocks'
|
||||
return t
|
||||
}
|
||||
|
||||
private parseStringArray(value: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map(v => String(v).trim()).filter(Boolean)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
return value.split(/[,,]/).map(v => v.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
private async resolveOtherCategoryId() {
|
||||
if (this.otherCategoryId !== undefined) return this.otherCategoryId
|
||||
|
||||
const byName = await this.db
|
||||
.selectFrom('categories')
|
||||
.select(['id'])
|
||||
.where('name', 'in', ['Other', 'OTHER', 'other', '未分类', '其它', '其他'])
|
||||
.executeTakeFirst()
|
||||
|
||||
if (byName?.id) {
|
||||
this.otherCategoryId = byName.id
|
||||
return this.otherCategoryId
|
||||
}
|
||||
|
||||
const byOrder = await this.db
|
||||
.selectFrom('categories')
|
||||
.select(['id'])
|
||||
.orderBy('order_index', 'desc')
|
||||
.executeTakeFirst()
|
||||
|
||||
if (byOrder?.id) {
|
||||
this.otherCategoryId = byOrder.id
|
||||
return this.otherCategoryId
|
||||
}
|
||||
|
||||
const id = uuidv4()
|
||||
const now = new Date().toISOString()
|
||||
await this.db
|
||||
.insertInto('categories')
|
||||
.values({
|
||||
id,
|
||||
name: 'Other',
|
||||
parent_id: null,
|
||||
rules: null,
|
||||
icon: null,
|
||||
order_index: 99,
|
||||
updated_at: now
|
||||
})
|
||||
.execute()
|
||||
|
||||
this.otherCategoryId = id
|
||||
return this.otherCategoryId
|
||||
}
|
||||
|
||||
private initSchema() {
|
||||
this.sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
server TEXT NOT NULL,
|
||||
server_port INTEGER NOT NULL,
|
||||
uuid TEXT,
|
||||
password TEXT,
|
||||
method TEXT,
|
||||
plugin TEXT,
|
||||
plugin_opts TEXT,
|
||||
network TEXT,
|
||||
security TEXT,
|
||||
path TEXT,
|
||||
host TEXT,
|
||||
tls TEXT,
|
||||
flow TEXT,
|
||||
packet_encoding TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
rules TEXT NOT NULL,
|
||||
chain_proxy INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_id TEXT,
|
||||
rules TEXT,
|
||||
icon TEXT,
|
||||
order_index INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS games (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT,
|
||||
process_name TEXT NOT NULL,
|
||||
category_id TEXT NOT NULL,
|
||||
tags TEXT,
|
||||
profile_id TEXT,
|
||||
last_played INTEGER,
|
||||
status TEXT,
|
||||
latency INTEGER,
|
||||
node_id TEXT,
|
||||
proxy_mode TEXT,
|
||||
routing_rules TEXT,
|
||||
chain_proxy INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
|
||||
// Lightweight migration for existing user DBs.
|
||||
this.ensureColumn('nodes', 'plugin', 'TEXT')
|
||||
this.ensureColumn('nodes', 'plugin_opts', 'TEXT')
|
||||
|
||||
this.initDefaultData()
|
||||
}
|
||||
|
||||
private ensureColumn(table: string, column: string, definition: string) {
|
||||
try {
|
||||
const cols = this.sqlite.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>
|
||||
if (!cols.some(c => c.name === column)) {
|
||||
this.sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to ensure column ${table}.${column}:`, e)
|
||||
}
|
||||
}
|
||||
|
||||
private async initDefaultData() {
|
||||
try {
|
||||
const result = this.sqlite.prepare('SELECT COUNT(*) as count FROM categories').get() as { count: number }
|
||||
const otherCategoryId = await this.resolveOtherCategoryId()
|
||||
|
||||
if (result && result.count === 0) {
|
||||
console.log('正在初始化默认分类...')
|
||||
const defaultCategories = [
|
||||
{ name: 'FPS', order: 1 },
|
||||
{ name: 'MOBA', order: 2 },
|
||||
{ name: 'RPG', order: 3 },
|
||||
{ name: 'Racing', order: 4 },
|
||||
{ name: 'Sports', order: 5 },
|
||||
{ name: 'Strategy', order: 6 },
|
||||
{ name: 'Survival', order: 7 },
|
||||
// 'Other' is handled by resolveOtherCategoryId
|
||||
]
|
||||
|
||||
const insert = this.sqlite.prepare('INSERT INTO categories (id, name, order_index) VALUES (@id, @name, @order)')
|
||||
const insertMany = this.sqlite.transaction((categories: typeof defaultCategories) => {
|
||||
for (const cat of categories) {
|
||||
insert.run({
|
||||
id: uuidv4(),
|
||||
name: cat.name,
|
||||
order: cat.order
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
insertMany(defaultCategories)
|
||||
console.log('默认分类初始化完成。')
|
||||
}
|
||||
|
||||
// Init default games (Bypass Mainland China & Global Mode)
|
||||
const gamesCount = this.sqlite.prepare('SELECT COUNT(*) as count FROM games').get() as { count: number }
|
||||
if (gamesCount && gamesCount.count === 0) {
|
||||
console.log('正在初始化默认模式...')
|
||||
const defaultGames = [
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: '绕过大陆 (Bypass CN)',
|
||||
process_name: '[]',
|
||||
category_id: otherCategoryId,
|
||||
proxy_mode: 'routing',
|
||||
routing_rules: JSON.stringify(['bypass_cn']),
|
||||
status: 'idle',
|
||||
latency: 0
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: '全局加速 (Global)',
|
||||
process_name: '[]',
|
||||
category_id: otherCategoryId,
|
||||
proxy_mode: 'routing',
|
||||
routing_rules: JSON.stringify([]),
|
||||
status: 'idle',
|
||||
latency: 0
|
||||
}
|
||||
]
|
||||
|
||||
const insertGame = this.sqlite.prepare(`
|
||||
INSERT INTO games (id, name, process_name, category_id, proxy_mode, routing_rules, status, latency, created_at, updated_at)
|
||||
VALUES (@id, @name, @process_name, @category_id, @proxy_mode, @routing_rules, @status, @latency, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`)
|
||||
|
||||
const insertManyGames = this.sqlite.transaction((games: typeof defaultGames) => {
|
||||
for (const game of games) {
|
||||
insertGame.run(game)
|
||||
}
|
||||
})
|
||||
|
||||
insertManyGames(defaultGames)
|
||||
console.log('默认模式初始化完成。')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('初始化默认数据失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async getAllNodes() {
|
||||
const rows = await this.db.selectFrom('nodes').selectAll().execute()
|
||||
return rows.map(row => ({
|
||||
...row,
|
||||
type: this.normalizeNodeType(row.type),
|
||||
tls: row.tls ? JSON.parse(row.tls) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
async saveNode(node: any) {
|
||||
const validColumns = [
|
||||
'id', 'type', 'tag', 'server', 'server_port',
|
||||
'uuid', 'password', 'method', 'plugin', 'plugin_opts', 'network', 'security',
|
||||
'path', 'host', 'tls', 'flow', 'packet_encoding',
|
||||
'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const id = node.id || uuidv4()
|
||||
|
||||
const nodeData: any = {
|
||||
id,
|
||||
updated_at: now
|
||||
}
|
||||
|
||||
for (const key of validColumns) {
|
||||
if (key === 'id' || key === 'updated_at') continue
|
||||
if (key === 'tls') {
|
||||
nodeData.tls = node.tls ? (typeof node.tls === 'string' ? node.tls : JSON.stringify(node.tls)) : null
|
||||
continue
|
||||
}
|
||||
if (key in node) {
|
||||
nodeData[key] = node[key]
|
||||
}
|
||||
}
|
||||
nodeData.type = this.normalizeNodeType(nodeData.type)
|
||||
|
||||
if (!node.created_at && !node.id) {
|
||||
nodeData.created_at = now
|
||||
} else if (node.created_at) {
|
||||
nodeData.created_at = node.created_at
|
||||
}
|
||||
|
||||
try {
|
||||
await this.db.insertInto('nodes')
|
||||
.values(nodeData)
|
||||
.onConflict(oc => oc.column('id').doUpdateSet(nodeData))
|
||||
.execute()
|
||||
} catch (e) {
|
||||
console.error('保存节点失败:', e)
|
||||
throw e
|
||||
}
|
||||
|
||||
return this.getAllNodes()
|
||||
}
|
||||
|
||||
async deleteNode(id: string) {
|
||||
await this.db.deleteFrom('nodes').where('id', '=', id).execute()
|
||||
return this.getAllNodes()
|
||||
}
|
||||
|
||||
async importNodes(nodes: any[]) {
|
||||
if (nodes.length === 0) return this.getAllNodes()
|
||||
|
||||
const values = nodes.map(node => ({
|
||||
...node,
|
||||
id: node.id || uuidv4(),
|
||||
type: this.normalizeNodeType(node.type),
|
||||
tls: node.tls ? JSON.stringify(node.tls) : null,
|
||||
updated_at: new Date().toISOString()
|
||||
}))
|
||||
|
||||
await this.db.transaction().execute(async (trx) => {
|
||||
await trx.insertInto('nodes').values(values).execute()
|
||||
})
|
||||
|
||||
return this.getAllNodes()
|
||||
}
|
||||
|
||||
async getAllGames() {
|
||||
const rows = await this.db.selectFrom('games').selectAll().execute()
|
||||
const otherCategoryId = await this.resolveOtherCategoryId()
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
iconUrl: row.icon && this.isIconUrl(row.icon) ? row.icon : undefined,
|
||||
processName: this.parseStringArray(row.process_name),
|
||||
category: row.category_id === 'other' ? (otherCategoryId || row.category_id) : row.category_id,
|
||||
tags: row.tags ? JSON.parse(row.tags) : undefined,
|
||||
profileId: row.profile_id || undefined,
|
||||
lastPlayed: row.last_played || undefined,
|
||||
status: row.status || undefined,
|
||||
latency: row.latency || undefined,
|
||||
nodeId: row.node_id || undefined,
|
||||
proxyMode: (row.proxy_mode || 'process') as any,
|
||||
routingRules: row.routing_rules ? this.parseStringArray(row.routing_rules) : undefined,
|
||||
chainProxy: Boolean(row.chain_proxy)
|
||||
}))
|
||||
}
|
||||
|
||||
async saveGame(game: any) {
|
||||
const otherCategoryId = await this.resolveOtherCategoryId()
|
||||
const iconValue = game.iconUrl || game.icon || ''
|
||||
const icon = typeof iconValue === 'string' && iconValue.trim() && this.isIconUrl(iconValue) ? iconValue.trim() : null
|
||||
const categoryId =
|
||||
game.category && game.category !== 'other'
|
||||
? game.category
|
||||
: (otherCategoryId || 'other')
|
||||
const gameData = {
|
||||
id: game.id || uuidv4(),
|
||||
name: game.name,
|
||||
icon,
|
||||
process_name: JSON.stringify(Array.isArray(game.processName) ? game.processName : [game.processName]),
|
||||
category_id: categoryId,
|
||||
tags: game.tags ? JSON.stringify(game.tags) : null,
|
||||
profile_id: game.profileId || null,
|
||||
last_played: game.lastPlayed || 0,
|
||||
status: game.status || 'idle',
|
||||
latency: game.latency || 0,
|
||||
node_id: game.nodeId || null,
|
||||
proxy_mode: game.proxyMode || 'process',
|
||||
routing_rules: game.routingRules ? JSON.stringify(game.routingRules) : null,
|
||||
chain_proxy: game.chainProxy ? 1 : 0,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
await this.db.insertInto('games')
|
||||
.values(gameData)
|
||||
.onConflict(oc => oc.column('id').doUpdateSet(gameData))
|
||||
.execute()
|
||||
|
||||
return this.getAllGames()
|
||||
}
|
||||
|
||||
async deleteGame(id: string) {
|
||||
await this.db.deleteFrom('games').where('id', '=', id).execute()
|
||||
return this.getAllGames()
|
||||
}
|
||||
|
||||
async getAllCategories() {
|
||||
const rows = await this.db.selectFrom('categories').selectAll().orderBy('order_index').execute()
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
parentId: row.parent_id || undefined,
|
||||
rules: row.rules ? JSON.parse(row.rules) : undefined,
|
||||
icon: row.icon || undefined,
|
||||
order: row.order_index
|
||||
}))
|
||||
}
|
||||
|
||||
async saveCategory(category: any) {
|
||||
const data = {
|
||||
id: category.id || uuidv4(),
|
||||
name: category.name,
|
||||
parent_id: category.parentId || null,
|
||||
rules: category.rules ? JSON.stringify(category.rules) : null,
|
||||
icon: category.icon || null,
|
||||
order_index: category.order || 0,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
await this.db.insertInto('categories')
|
||||
.values(data)
|
||||
.onConflict(oc => oc.column('id').doUpdateSet(data))
|
||||
.execute()
|
||||
|
||||
return this.getAllCategories()
|
||||
}
|
||||
|
||||
async deleteCategory(id: string) {
|
||||
await this.db.deleteFrom('categories').where('id', '=', id).execute()
|
||||
return this.getAllCategories()
|
||||
}
|
||||
|
||||
async getAllProfiles() {
|
||||
const rows = await this.db.selectFrom('profiles').selectAll().execute()
|
||||
return rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description || undefined,
|
||||
rules: JSON.parse(row.rules),
|
||||
chainProxy: Boolean(row.chain_proxy)
|
||||
}))
|
||||
}
|
||||
|
||||
async saveProfile(profile: any) {
|
||||
const data = {
|
||||
id: profile.id || uuidv4(),
|
||||
name: profile.name,
|
||||
description: profile.description || null,
|
||||
rules: JSON.stringify(profile.rules || []),
|
||||
chain_proxy: profile.chainProxy ? 1 : 0,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
await this.db.insertInto('profiles')
|
||||
.values(data)
|
||||
.onConflict(oc => oc.column('id').doUpdateSet(data))
|
||||
.execute()
|
||||
|
||||
return this.getAllProfiles()
|
||||
}
|
||||
|
||||
async deleteProfile(id: string) {
|
||||
await this.db.deleteFrom('profiles').where('id', '=', id).execute()
|
||||
return this.getAllProfiles()
|
||||
}
|
||||
|
||||
async exportData() {
|
||||
const nodes = await this.getAllNodes()
|
||||
const games = await this.getAllGames()
|
||||
const categories = await this.getAllCategories()
|
||||
const profiles = await this.getAllProfiles()
|
||||
|
||||
return {
|
||||
version: '1.0',
|
||||
timestamp: new Date().toISOString(),
|
||||
nodes,
|
||||
games,
|
||||
categories,
|
||||
profiles
|
||||
}
|
||||
}
|
||||
|
||||
async importData(data: any) {
|
||||
const otherCategoryId = await this.resolveOtherCategoryId()
|
||||
await this.db.transaction().execute(async (trx) => {
|
||||
if (data.nodes && Array.isArray(data.nodes)) {
|
||||
for (const node of data.nodes) {
|
||||
const nodeData = {
|
||||
...node,
|
||||
id: node.id || uuidv4(),
|
||||
type: this.normalizeNodeType(node.type),
|
||||
tls: node.tls ? JSON.stringify(node.tls) : null,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
await trx.insertInto('nodes').values(nodeData).onConflict(oc => oc.column('id').doUpdateSet(nodeData)).execute()
|
||||
}
|
||||
}
|
||||
|
||||
if (data.categories) {
|
||||
for (const cat of data.categories) {
|
||||
const catData = {
|
||||
id: cat.id || uuidv4(),
|
||||
name: cat.name,
|
||||
parent_id: cat.parentId || null,
|
||||
rules: cat.rules ? JSON.stringify(cat.rules) : null,
|
||||
icon: cat.icon || null,
|
||||
order_index: cat.order || 0,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
await trx.insertInto('categories').values(catData).onConflict(oc => oc.column('id').doUpdateSet(catData)).execute()
|
||||
}
|
||||
}
|
||||
|
||||
if (data.games) {
|
||||
for (const game of data.games) {
|
||||
const categoryId =
|
||||
game.category && game.category !== 'other'
|
||||
? game.category
|
||||
: (otherCategoryId || 'other')
|
||||
const gameData = {
|
||||
id: game.id || uuidv4(),
|
||||
name: game.name,
|
||||
icon: game.iconUrl || game.icon || null,
|
||||
process_name: JSON.stringify(Array.isArray(game.processName) ? game.processName : [game.processName]),
|
||||
category_id: categoryId,
|
||||
tags: game.tags ? JSON.stringify(game.tags) : null,
|
||||
profile_id: game.profileId || null,
|
||||
last_played: game.lastPlayed || 0,
|
||||
status: game.status || 'idle',
|
||||
latency: game.latency || 0,
|
||||
node_id: game.nodeId || null,
|
||||
proxy_mode: game.proxyMode || 'process',
|
||||
routing_rules: game.routingRules ? JSON.stringify(game.routingRules) : null,
|
||||
chain_proxy: game.chainProxy ? 1 : 0,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
await trx.insertInto('games').values(gameData).onConflict(oc => oc.column('id').doUpdateSet(gameData)).execute()
|
||||
}
|
||||
}
|
||||
|
||||
if (data.profiles) {
|
||||
for (const profile of data.profiles) {
|
||||
const pData = {
|
||||
id: profile.id || uuidv4(),
|
||||
name: profile.name,
|
||||
description: profile.description || null,
|
||||
rules: JSON.stringify(profile.rules || []),
|
||||
chain_proxy: profile.chainProxy ? 1 : 0,
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
await trx.insertInto('profiles').values(pData).onConflict(oc => oc.column('id').doUpdateSet(pData)).execute()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Generated } from 'kysely'
|
||||
|
||||
export interface NodesTable {
|
||||
id: string
|
||||
type: string
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
uuid: string | null
|
||||
password: string | null
|
||||
method: string | null
|
||||
plugin: string | null
|
||||
plugin_opts: string | null
|
||||
network: string | null
|
||||
security: string | null
|
||||
path: string | null
|
||||
host: string | null
|
||||
tls: string | null // JSON
|
||||
flow: string | null
|
||||
packet_encoding: string | null
|
||||
created_at: Generated<string>
|
||||
updated_at: Generated<string>
|
||||
}
|
||||
|
||||
export interface ProfilesTable {
|
||||
id: string
|
||||
name: string
|
||||
description: string | null
|
||||
rules: string // JSON: { type, value, outbound }[]
|
||||
chain_proxy: number // boolean 0/1
|
||||
created_at: Generated<string>
|
||||
updated_at: Generated<string>
|
||||
}
|
||||
|
||||
export interface CategoriesTable {
|
||||
id: string
|
||||
name: string
|
||||
parent_id: string | null
|
||||
rules: string | null // JSON string[]
|
||||
icon: string | null
|
||||
order_index: number
|
||||
created_at: Generated<string>
|
||||
updated_at: Generated<string>
|
||||
}
|
||||
|
||||
export interface GamesTable {
|
||||
id: string
|
||||
name: string
|
||||
icon: string | null
|
||||
process_name: string // JSON string[] or comma separated? Let's use JSON for string[]
|
||||
category_id: string
|
||||
tags: string | null // JSON string[]
|
||||
profile_id: string | null
|
||||
last_played: number | null
|
||||
status: string | null
|
||||
latency: number | null
|
||||
node_id: string | null
|
||||
proxy_mode: string | null // 'process' | 'routing'
|
||||
routing_rules: string | null // JSON string[]
|
||||
chain_proxy: number // boolean 0/1
|
||||
created_at: Generated<string>
|
||||
updated_at: Generated<string>
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
nodes: NodesTable
|
||||
profiles: ProfilesTable
|
||||
categories: CategoriesTable
|
||||
games: GamesTable
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { DatabaseManager } from '../db'
|
||||
|
||||
export interface Game {
|
||||
id?: string
|
||||
name: string
|
||||
iconUrl?: string
|
||||
processName: string | string[]
|
||||
category: string
|
||||
tags?: string[]
|
||||
profileId?: string
|
||||
lastPlayed?: number
|
||||
status?: 'idle' | 'accelerating'
|
||||
latency?: number
|
||||
nodeId?: string
|
||||
proxyMode?: 'process' | 'routing'
|
||||
routingRules?: string[]
|
||||
chainProxy?: boolean
|
||||
}
|
||||
|
||||
export class GameManager {
|
||||
private db: DatabaseManager
|
||||
|
||||
constructor(db: DatabaseManager) {
|
||||
this.db = db
|
||||
this.registerIPC()
|
||||
}
|
||||
|
||||
private registerIPC() {
|
||||
ipcMain.handle('games:get-all', () => this.db.getAllGames())
|
||||
|
||||
ipcMain.handle('games:save', async (_, game: Game) => {
|
||||
return this.db.saveGame(game)
|
||||
})
|
||||
|
||||
ipcMain.handle('games:delete', async (_, id: string) => {
|
||||
return this.db.deleteGame(id)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { app, BrowserWindow, ipcMain, dialog } from 'electron'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import path from 'node:path'
|
||||
import fs from 'fs-extra'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
// Handle __filename error in ESM
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
// Polyfill global.__filename and global.__dirname for better-sqlite3
|
||||
// @ts-ignore
|
||||
global.__filename = __filename
|
||||
// @ts-ignore
|
||||
global.__dirname = __dirname
|
||||
|
||||
import { SingBoxManager } from './singbox/manager'
|
||||
import { ProcessManager } from './process/manager'
|
||||
import { GameManager } from './games/manager'
|
||||
import { CategoryManager } from './categories/manager'
|
||||
import { ProxyMonitor } from './proxy/monitor'
|
||||
import { NodeManager } from './nodes/manager'
|
||||
import { DatabaseManager } from './db'
|
||||
import { ping, tcpPing } from './network/ping'
|
||||
|
||||
process.env.APP_ROOT = path.join(__dirname, '..')
|
||||
|
||||
// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
|
||||
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
|
||||
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
|
||||
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
|
||||
|
||||
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
app.setPath('userData', path.join(process.env.APP_ROOT, '.lagzero-dev'))
|
||||
}
|
||||
|
||||
let win: BrowserWindow | null
|
||||
let singboxManager: SingBoxManager | null = null
|
||||
let processManager: ProcessManager | null = null
|
||||
let gameManager: GameManager | null = null
|
||||
let categoryManager: CategoryManager | null = null
|
||||
let proxyMonitor: ProxyMonitor | null = null
|
||||
let nodeManager: NodeManager | null = null
|
||||
let dbManager: DatabaseManager | null = null
|
||||
|
||||
function createWindow() {
|
||||
win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
frame: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false, // Allow loading local resources (file://)
|
||||
},
|
||||
backgroundColor: '#1e1e1e',
|
||||
})
|
||||
|
||||
dbManager = new DatabaseManager()
|
||||
singboxManager = new SingBoxManager(win)
|
||||
processManager = new ProcessManager()
|
||||
gameManager = new GameManager(dbManager)
|
||||
categoryManager = new CategoryManager(dbManager)
|
||||
proxyMonitor = new ProxyMonitor(win, processManager, singboxManager)
|
||||
nodeManager = new NodeManager(dbManager)
|
||||
|
||||
// Register generic DB IPCs
|
||||
ipcMain.handle('db:export', () => dbManager?.exportData())
|
||||
ipcMain.handle('db:import', (_, data) => dbManager?.importData(data))
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL)
|
||||
win.webContents.openDevTools()
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
|
||||
}
|
||||
}
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
|
||||
app.whenReady().then(createWindow)
|
||||
|
||||
ipcMain.handle('window-minimize', () => win?.minimize())
|
||||
ipcMain.handle('window-maximize', () => {
|
||||
if (win && win.isMaximized()) {
|
||||
win.unmaximize()
|
||||
} else if (win) {
|
||||
win.maximize()
|
||||
}
|
||||
})
|
||||
ipcMain.handle('window-close', () => win?.close())
|
||||
|
||||
ipcMain.handle('dialog:pick-image', async () => {
|
||||
if (!win) return null
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'ico', 'svg'] }
|
||||
]
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
return pathToFileURL(result.filePaths[0]!).toString()
|
||||
})
|
||||
|
||||
ipcMain.handle('dialog:pick-process', async () => {
|
||||
if (!win) return null
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: [
|
||||
{ name: 'Executables', extensions: ['exe'] },
|
||||
{ name: 'All Files', extensions: ['*'] }
|
||||
]
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
// Return filenames only, as users usually just need the process name (e.g. game.exe)
|
||||
// But maybe they want full path? The UI shows "e.g. League of Legends.exe".
|
||||
// The backend matcher uses `game.processName` which seems to be just filename usually,
|
||||
// but let's see.
|
||||
// `matchRunningGames` in store uses `t.toLowerCase() === pNameLower`. `pName` from `scanProcesses` is just name.
|
||||
// So we should return basenames.
|
||||
return result.filePaths.map(p => path.basename(p))
|
||||
})
|
||||
|
||||
async function scanDir(dir: string, maxDepth: number, currentDepth: number = 1): Promise<string[]> {
|
||||
let results: string[] = []
|
||||
try {
|
||||
const dirents = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const dirent of dirents) {
|
||||
if (dirent.isDirectory()) {
|
||||
if (maxDepth === -1 || currentDepth < maxDepth) {
|
||||
const subResults = await scanDir(path.join(dir, dirent.name), maxDepth, currentDepth + 1)
|
||||
results.push(...subResults)
|
||||
}
|
||||
} else if (dirent.isFile() && dirent.name.toLowerCase().endsWith('.exe')) {
|
||||
results.push(dirent.name)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error scanning directory ${dir}:`, err)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
ipcMain.handle('dialog:pick-process-folder', async (_, maxDepth: number = 1) => {
|
||||
if (!win) return null
|
||||
const result = await dialog.showOpenDialog(win, {
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
if (result.canceled || result.filePaths.length === 0) return null
|
||||
const dir = result.filePaths[0]
|
||||
return scanDir(dir, maxDepth)
|
||||
})
|
||||
|
||||
ipcMain.handle('system:ping', async (_, host: string) => ping(host))
|
||||
ipcMain.handle('system:tcp-ping', async (_, host: string, port: number) => {
|
||||
try {
|
||||
return await tcpPing(host, port)
|
||||
} catch (e: any) {
|
||||
return { latency: -1, loss: 100 }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
|
||||
import { exec } from 'node:child_process'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
|
||||
interface PingResult {
|
||||
latency: number
|
||||
loss: number
|
||||
}
|
||||
|
||||
const isWindows = os.platform() === 'win32'
|
||||
|
||||
export function ping(host: string, timeout = 2000): Promise<PingResult> {
|
||||
return new Promise((resolve) => {
|
||||
// Windows: -n 1 (count), -w timeout (ms)
|
||||
// Linux/Mac: -c 1 (count), -W timeout (s)
|
||||
|
||||
// Safety check for host to prevent injection
|
||||
if (/[^a-zA-Z0-9.-]/.test(host)) {
|
||||
return resolve({ latency: -1, loss: 100 })
|
||||
}
|
||||
|
||||
const cmd = isWindows
|
||||
? `ping -n 1 -w ${timeout} ${host}`
|
||||
: `ping -c 1 -W ${Math.ceil(timeout / 1000)} ${host}`
|
||||
|
||||
const start = Date.now()
|
||||
exec(cmd, (error, stdout, stderr) => {
|
||||
const duration = Date.now() - start
|
||||
|
||||
if (error) {
|
||||
// Ping failed (timeout or other error)
|
||||
resolve({ latency: -1, loss: 100 })
|
||||
return
|
||||
}
|
||||
|
||||
// Parse output
|
||||
let latency = -1
|
||||
if (isWindows) {
|
||||
// Windows output formats:
|
||||
// EN: Reply from 1.1.1.1: bytes=32 time=13ms TTL=56
|
||||
// CN: 来自 1.1.1.1 的回复: 字节=32 时间=13ms TTL=56
|
||||
const match = stdout.match(/[=<]([\d\.]+) ?ms/)
|
||||
if (match && match[1]) {
|
||||
latency = Math.round(parseFloat(match[1]))
|
||||
}
|
||||
} else {
|
||||
// 64 bytes from 1.1.1.1: icmp_seq=1 ttl=56 time=14.5 ms
|
||||
const match = stdout.match(/time[=<]([\d\.]+) ?ms/)
|
||||
if (match && match[1]) {
|
||||
latency = Math.round(parseFloat(match[1]))
|
||||
}
|
||||
}
|
||||
|
||||
resolve({
|
||||
latency: latency > 0 ? latency : -1,
|
||||
loss: latency > 0 ? 0 : 100
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function tcpPing(host: string, port: number, timeout = 2000): Promise<PingResult> {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now()
|
||||
const socket = new net.Socket()
|
||||
|
||||
let resolved = false
|
||||
|
||||
const done = (latency: number, loss: number) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
socket.destroy()
|
||||
resolve({ latency, loss })
|
||||
}
|
||||
|
||||
socket.setTimeout(timeout)
|
||||
|
||||
socket.on('connect', () => {
|
||||
const duration = Date.now() - start
|
||||
done(duration, 0)
|
||||
})
|
||||
|
||||
socket.on('timeout', () => {
|
||||
done(-1, 100)
|
||||
})
|
||||
|
||||
socket.on('error', (err) => {
|
||||
done(-1, 100)
|
||||
})
|
||||
|
||||
try {
|
||||
socket.connect(port, host)
|
||||
} catch (e) {
|
||||
done(-1, 100)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { DatabaseManager } from '../db'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
// Reuse types from frontend via shared or duplicate for now to avoid complexity
|
||||
interface NodeConfig {
|
||||
id: string
|
||||
type: string
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
uuid?: string
|
||||
password?: string
|
||||
method?: string
|
||||
plugin?: string
|
||||
plugin_opts?: string
|
||||
network?: string
|
||||
security?: string
|
||||
path?: string
|
||||
host?: string
|
||||
tls?: {
|
||||
enabled: boolean
|
||||
server_name?: string
|
||||
insecure?: boolean
|
||||
utls?: {
|
||||
enabled: boolean
|
||||
fingerprint: string
|
||||
}
|
||||
}
|
||||
flow?: string
|
||||
packet_encoding?: string
|
||||
}
|
||||
|
||||
export class NodeManager {
|
||||
private db: DatabaseManager
|
||||
|
||||
constructor(db: DatabaseManager) {
|
||||
this.db = db
|
||||
this.registerIPC()
|
||||
}
|
||||
|
||||
public getAll() {
|
||||
return this.db.getAllNodes()
|
||||
}
|
||||
|
||||
public save(node: Partial<NodeConfig>) {
|
||||
return this.db.saveNode(node)
|
||||
}
|
||||
|
||||
public delete(id: string) {
|
||||
return this.db.deleteNode(id)
|
||||
}
|
||||
|
||||
public importNodes(newNodes: Partial<NodeConfig>[]) {
|
||||
return this.db.importNodes(newNodes)
|
||||
}
|
||||
|
||||
private registerIPC() {
|
||||
ipcMain.handle('nodes:get-all', () => this.getAll())
|
||||
ipcMain.handle('nodes:save', (_, node: any) => this.save(node))
|
||||
ipcMain.handle('nodes:delete', (_, id: string) => this.delete(id))
|
||||
ipcMain.handle('nodes:import', (_, nodes: any[]) => this.importNodes(nodes))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
const ipcListenerMap = new Map<string, Map<(...args: any[]) => void, (...args: any[]) => void>>()
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
minimize: () => ipcRenderer.invoke('window-minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window-maximize'),
|
||||
close: () => ipcRenderer.invoke('window-close'),
|
||||
pickImage: () => ipcRenderer.invoke('dialog:pick-image'),
|
||||
pickProcess: () => ipcRenderer.invoke('dialog:pick-process'),
|
||||
pickProcessFolder: (maxDepth?: number) => ipcRenderer.invoke('dialog:pick-process-folder', maxDepth),
|
||||
on: (channel: string, callback: (...args: any[]) => void) => {
|
||||
const wrapped = (_event: any, ...args: any[]) => callback(...args)
|
||||
const channelMap = ipcListenerMap.get(channel) || new Map()
|
||||
channelMap.set(callback, wrapped)
|
||||
ipcListenerMap.set(channel, channelMap)
|
||||
ipcRenderer.on(channel, wrapped)
|
||||
},
|
||||
off: (channel: string, callback: (...args: any[]) => void) => {
|
||||
const channelMap = ipcListenerMap.get(channel)
|
||||
const wrapped = channelMap?.get(callback)
|
||||
if (!wrapped) return
|
||||
ipcRenderer.removeListener(channel, wrapped)
|
||||
channelMap?.delete(callback)
|
||||
}
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('singbox', {
|
||||
start: (config: string) => ipcRenderer.invoke('singbox-start', config),
|
||||
stop: () => ipcRenderer.invoke('singbox-stop'),
|
||||
restart: (config: string) => ipcRenderer.invoke('singbox-restart', config),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('system', {
|
||||
scanProcesses: () => ipcRenderer.invoke('process-scan'),
|
||||
getProcessTree: () => ipcRenderer.invoke('process-tree'),
|
||||
ping: (host: string) => ipcRenderer.invoke('system:ping', host),
|
||||
tcpPing: (host: string, port: number) => ipcRenderer.invoke('system:tcp-ping', host, port),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('proxyMonitor', {
|
||||
start: (gameId: string, processNames: string[]) => ipcRenderer.invoke('proxy-monitor:start', gameId, processNames),
|
||||
stop: () => ipcRenderer.invoke('proxy-monitor:stop'),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('nodes', {
|
||||
getAll: () => ipcRenderer.invoke('nodes:get-all'),
|
||||
save: (node: any) => ipcRenderer.invoke('nodes:save', node),
|
||||
delete: (id: string) => ipcRenderer.invoke('nodes:delete', id),
|
||||
import: (nodes: any[]) => ipcRenderer.invoke('nodes:import', nodes),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('games', {
|
||||
getAll: () => ipcRenderer.invoke('games:get-all'),
|
||||
save: (game: any) => ipcRenderer.invoke('games:save', game),
|
||||
delete: (id: string) => ipcRenderer.invoke('games:delete', id),
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('categories', {
|
||||
getAll: () => ipcRenderer.invoke('categories:get-all'),
|
||||
save: (category: any) => ipcRenderer.invoke('categories:save', category),
|
||||
delete: (id: string) => ipcRenderer.invoke('categories:delete', id),
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
import { exec } from 'child_process'
|
||||
import { ipcMain } from 'electron'
|
||||
import { promisify } from 'util'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export class ProcessManager {
|
||||
constructor() {
|
||||
this.setupIPC()
|
||||
}
|
||||
|
||||
private setupIPC() {
|
||||
ipcMain.handle('process-scan', async () => {
|
||||
return await this.scanProcesses()
|
||||
})
|
||||
ipcMain.handle('process-tree', async () => {
|
||||
return await this.getProcessTree()
|
||||
})
|
||||
}
|
||||
|
||||
async scanProcesses(): Promise<string[]> {
|
||||
try {
|
||||
let command = ''
|
||||
if (process.platform === 'win32') {
|
||||
command = 'tasklist /FO CSV /NH'
|
||||
} else if (process.platform === 'darwin' || process.platform === 'linux') {
|
||||
command = 'ps -e -o comm='
|
||||
}
|
||||
|
||||
if (!command) return []
|
||||
|
||||
const { stdout } = await execAsync(command)
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
// Parse CSV format from tasklist
|
||||
// "Image Name","PID","Session Name","Session#","Mem Usage"
|
||||
return stdout
|
||||
.split('\r\n')
|
||||
.map(line => {
|
||||
const match = line.match(/^"([^"]+)"/)
|
||||
return match ? match[1] : ''
|
||||
})
|
||||
.filter(name => name)
|
||||
} else {
|
||||
// Parse simple list for unix
|
||||
return stdout
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(name => name)
|
||||
.map(path => {
|
||||
// Extract filename from path
|
||||
const parts = path.split('/')
|
||||
return parts[parts.length - 1]
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to scan processes:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getProcessTree(): Promise<any[]> {
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
// Use wmic to get process tree info
|
||||
const { stdout } = await execAsync('wmic process get ProcessId,ParentProcessId,Name,ExecutablePath /FORMAT:CSV')
|
||||
const lines = stdout.trim().split('\r\n').slice(1) // Skip header
|
||||
const processes = lines.map(line => {
|
||||
const parts = line.split(',')
|
||||
// Node,ExecutablePath,Name,ParentProcessId,ProcessId
|
||||
// wmic CSV output might contain empty lines or different column order depending on locale?
|
||||
// Actually wmic /FORMAT:CSV output order is fixed: Node, ...columns sorted alphabetically
|
||||
// Columns: ExecutablePath, Name, ParentProcessId, ProcessId
|
||||
if (parts.length < 5) return null
|
||||
return {
|
||||
path: parts[1],
|
||||
name: parts[2],
|
||||
ppid: parseInt(parts[3], 10),
|
||||
pid: parseInt(parts[4], 10),
|
||||
children: []
|
||||
}
|
||||
}).filter(p => p && p.pid)
|
||||
|
||||
// Build tree
|
||||
const processMap = new Map<number, any>()
|
||||
processes.forEach(p => processMap.set(p.pid, p))
|
||||
|
||||
const rootProcesses: any[] = []
|
||||
processes.forEach(p => {
|
||||
if (p.ppid && processMap.has(p.ppid)) {
|
||||
processMap.get(p.ppid).children.push(p)
|
||||
} else {
|
||||
rootProcesses.push(p)
|
||||
}
|
||||
})
|
||||
return rootProcesses
|
||||
}
|
||||
return []
|
||||
} catch (error) {
|
||||
console.error('Failed to get process tree:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
import { ProcessManager } from '../process/manager';
|
||||
import { SingBoxManager } from '../singbox/manager';
|
||||
|
||||
interface ProcessInfo {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
name: string;
|
||||
children?: ProcessInfo[];
|
||||
}
|
||||
|
||||
export class ProxyMonitor {
|
||||
private mainWindow: BrowserWindow;
|
||||
private processManager: ProcessManager;
|
||||
private singboxManager: SingBoxManager;
|
||||
private interval: NodeJS.Timeout | null = null;
|
||||
private monitoredProcessNames: Set<string> = new Set();
|
||||
private activeGameId: string | null = null;
|
||||
private detectedChildProcesses: Set<string> = new Set();
|
||||
|
||||
constructor(window: BrowserWindow, processManager: ProcessManager, singboxManager: SingBoxManager) {
|
||||
this.mainWindow = window;
|
||||
this.processManager = processManager;
|
||||
this.singboxManager = singboxManager;
|
||||
this.setupIPC();
|
||||
}
|
||||
|
||||
private setupIPC() {
|
||||
ipcMain.handle('proxy-monitor:start', (_, gameId: string, processNames: string[]) => {
|
||||
this.startMonitoring(gameId, processNames);
|
||||
});
|
||||
|
||||
ipcMain.handle('proxy-monitor:stop', () => {
|
||||
this.stopMonitoring();
|
||||
});
|
||||
}
|
||||
|
||||
startMonitoring(gameId: string, processNames: string[]) {
|
||||
this.stopMonitoring();
|
||||
this.activeGameId = gameId;
|
||||
this.monitoredProcessNames = new Set(processNames);
|
||||
this.detectedChildProcesses.clear();
|
||||
|
||||
console.log(`[ProxyMonitor] Started monitoring for game ${gameId} with processes:`, processNames);
|
||||
|
||||
// Start polling
|
||||
this.interval = setInterval(() => this.checkChainProxy(), 3000);
|
||||
this.mainWindow.webContents.send('proxy-monitor:status', { status: 'active', gameId });
|
||||
}
|
||||
|
||||
stopMonitoring() {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
this.interval = null;
|
||||
}
|
||||
this.activeGameId = null;
|
||||
this.monitoredProcessNames.clear();
|
||||
this.detectedChildProcesses.clear();
|
||||
this.mainWindow.webContents.send('proxy-monitor:status', { status: 'idle' });
|
||||
console.log('[ProxyMonitor] Stopped monitoring');
|
||||
}
|
||||
|
||||
private async checkChainProxy() {
|
||||
if (!this.activeGameId) return;
|
||||
|
||||
try {
|
||||
const tree = await this.processManager.getProcessTree();
|
||||
const newChildren = this.findNewChildren(tree);
|
||||
|
||||
if (newChildren.length > 0) {
|
||||
// Add new children to monitored set
|
||||
newChildren.forEach(name => {
|
||||
if (!this.monitoredProcessNames.has(name)) {
|
||||
this.monitoredProcessNames.add(name);
|
||||
this.detectedChildProcesses.add(name);
|
||||
console.log(`[ProxyMonitor] Detected new child process: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Notify frontend
|
||||
this.mainWindow.webContents.send('proxy-monitor:detected', newChildren);
|
||||
|
||||
// TODO: Dynamically update SingBox rules
|
||||
// this.singboxManager.updateRules([...this.monitoredProcessNames]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Proxy monitor error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private findNewChildren(nodes: ProcessInfo[]): string[] {
|
||||
const found: string[] = [];
|
||||
|
||||
// Recursive traversal to find children of monitored processes
|
||||
const traverse = (node: ProcessInfo, isProxiedParent: boolean) => {
|
||||
const isMonitored = this.monitoredProcessNames.has(node.name);
|
||||
// If parent is proxied, child should be proxied (Chain Proxy)
|
||||
const shouldProxy = isProxiedParent || isMonitored;
|
||||
|
||||
if (shouldProxy && !isMonitored) {
|
||||
found.push(node.name);
|
||||
}
|
||||
|
||||
if (node.children) {
|
||||
node.children.forEach(child => traverse(child, shouldProxy));
|
||||
}
|
||||
};
|
||||
|
||||
nodes.forEach(node => traverse(node, false));
|
||||
return found;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
import { spawn, ChildProcess } from 'child_process'
|
||||
import path from 'path'
|
||||
import fs from 'fs-extra'
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import AdmZip from 'adm-zip'
|
||||
import * as tar from 'tar'
|
||||
import { pipeline } from 'stream/promises'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { Readable } from 'stream'
|
||||
import https from 'node:https'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const BIN_DIR = path.join(app.getPath('userData'), 'bin')
|
||||
|
||||
export class SingBoxManager {
|
||||
private process: ChildProcess | null = null
|
||||
private mainWindow: BrowserWindow | null = null
|
||||
private retryCount = 0
|
||||
private maxRetries = 3
|
||||
private downloadPromise: Promise<string> | null = null
|
||||
private lastLogs: string[] = []
|
||||
private isStopping = false
|
||||
|
||||
constructor(window: BrowserWindow) {
|
||||
this.mainWindow = window
|
||||
this.setupIPC()
|
||||
}
|
||||
|
||||
private setupIPC() {
|
||||
ipcMain.handle('singbox-start', async (_, configContent: string) => {
|
||||
const configPath = path.join(app.getPath('userData'), 'config.json')
|
||||
await fs.writeFile(configPath, configContent)
|
||||
await this.start(configPath)
|
||||
})
|
||||
ipcMain.handle('singbox-stop', () => this.stop())
|
||||
ipcMain.handle('singbox-restart', async (_, configContent: string) => {
|
||||
this.stop()
|
||||
const configPath = path.join(app.getPath('userData'), 'config.json')
|
||||
await fs.writeFile(configPath, configContent)
|
||||
await this.start(configPath)
|
||||
})
|
||||
}
|
||||
|
||||
async checkAndDownloadBinary() {
|
||||
await fs.ensureDir(BIN_DIR)
|
||||
const platform = process.platform
|
||||
const arch = process.arch
|
||||
const ext = platform === 'win32' ? '.exe' : ''
|
||||
const binPath = path.join(BIN_DIR, `sing-box${ext}`)
|
||||
|
||||
if (await fs.pathExists(binPath)) {
|
||||
return binPath
|
||||
}
|
||||
|
||||
if (!this.downloadPromise) {
|
||||
this.downloadPromise = this.downloadAndInstallBinary(platform, arch)
|
||||
.finally(() => {
|
||||
this.downloadPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return this.downloadPromise
|
||||
}
|
||||
|
||||
async start(configPath: string) {
|
||||
if (this.process) {
|
||||
this.log('Process already running', 'info')
|
||||
return
|
||||
}
|
||||
|
||||
this.isStopping = false
|
||||
const binPath = await this.checkAndDownloadBinary()
|
||||
if (!await fs.pathExists(binPath)) {
|
||||
const msg = 'sing-box 可执行文件不存在,无法启动加速'
|
||||
this.log(msg, 'error')
|
||||
this.mainWindow?.webContents.send('singbox-error', msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
await this.validateConfig(binPath, configPath)
|
||||
|
||||
this.log('正在启动 sing-box...')
|
||||
this.lastLogs = []
|
||||
const pushLog = (line: string) => {
|
||||
const trimmed = this.stripAnsi(String(line || '').trim())
|
||||
if (!trimmed) return
|
||||
this.lastLogs.push(trimmed)
|
||||
if (this.lastLogs.length > 80) this.lastLogs.shift()
|
||||
}
|
||||
|
||||
this.process = spawn(binPath, ['run', '-c', configPath], { windowsHide: true, env: this.getSingboxEnv() })
|
||||
|
||||
this.process.stdout?.on('data', (data) => {
|
||||
const text = data.toString().trim()
|
||||
pushLog(text)
|
||||
this.log(this.stripAnsi(text))
|
||||
})
|
||||
|
||||
this.process.stderr?.on('data', (data) => {
|
||||
const text = data.toString().trim()
|
||||
pushLog(text)
|
||||
this.log(this.stripAnsi(text), 'error')
|
||||
})
|
||||
|
||||
const started = await new Promise<void>((resolve, reject) => {
|
||||
let settled = false
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
fn()
|
||||
}
|
||||
|
||||
const startupTimer = setTimeout(() => {
|
||||
settle(() => resolve())
|
||||
}, 800)
|
||||
|
||||
this.process?.once('error', (err) => {
|
||||
clearTimeout(startupTimer)
|
||||
const msg = `sing-box 启动失败:${String(err?.message || err)}`
|
||||
this.log(msg, 'error')
|
||||
this.mainWindow?.webContents.send('singbox-error', msg)
|
||||
settle(() => reject(new Error(msg)))
|
||||
})
|
||||
|
||||
this.process?.once('close', (code) => {
|
||||
if (!settled) {
|
||||
clearTimeout(startupTimer)
|
||||
const detail = this.buildHelpfulErrorDetail(this.lastLogs)
|
||||
const last = this.lastLogs.at(-1)
|
||||
const msg = `sing-box 启动后立即退出(code=${String(code)})${last ? `:${last}` : ''}${detail ? `\n\n${detail}` : ''}`
|
||||
this.log(msg, 'error')
|
||||
this.mainWindow?.webContents.send('singbox-error', msg)
|
||||
settle(() => reject(new Error(msg)))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
this.process.on('close', (code) => {
|
||||
this.log(`sing-box exited with code ${code}`)
|
||||
this.process = null
|
||||
this.sendStatus('stopped')
|
||||
|
||||
if (this.isStopping) return
|
||||
if (code !== 0 && this.retryCount < this.maxRetries) {
|
||||
this.retryCount++
|
||||
this.log(`Retrying start (${this.retryCount}/${this.maxRetries})...`)
|
||||
setTimeout(() => this.start(configPath), 2000)
|
||||
} else if (code !== 0) {
|
||||
const detail = this.buildHelpfulErrorDetail(this.lastLogs)
|
||||
const last = this.lastLogs.at(-1)
|
||||
const msg = `sing-box 启动失败(已重试 ${this.maxRetries} 次)${last ? `:${last}` : ''}${detail ? `\n\n${detail}` : ''}`
|
||||
this.log(msg, 'error')
|
||||
this.mainWindow?.webContents.send('singbox-error', msg)
|
||||
}
|
||||
})
|
||||
|
||||
await started
|
||||
this.sendStatus('running')
|
||||
this.retryCount = 0
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.process) {
|
||||
this.isStopping = true
|
||||
this.retryCount = 0
|
||||
const current = this.process
|
||||
const pid = typeof current.pid === 'number' ? current.pid : null
|
||||
current.kill('SIGINT')
|
||||
setTimeout(() => {
|
||||
if (!this.process) return
|
||||
if (this.process !== current) return
|
||||
if (process.platform === 'win32' && pid) {
|
||||
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true })
|
||||
} else {
|
||||
this.process.kill('SIGKILL')
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
}
|
||||
|
||||
private log(message: string, type: 'info' | 'error' = 'info') {
|
||||
console.log(`[SingBox] ${message}`)
|
||||
this.mainWindow?.webContents.send('singbox-log', { message, type, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
private sendStatus(status: 'running' | 'stopped') {
|
||||
this.mainWindow?.webContents.send('singbox-status', status)
|
||||
}
|
||||
|
||||
private async validateConfig(binPath: string, configPath: string): Promise<void> {
|
||||
const { code, output } = await this.runOnce(binPath, ['check', '-c', configPath])
|
||||
if (code === 0) return
|
||||
|
||||
const lines = output
|
||||
.split(/\r?\n/g)
|
||||
.map(l => this.stripAnsi(l.trim()))
|
||||
.filter(Boolean)
|
||||
.slice(-12)
|
||||
|
||||
const detail = this.buildHelpfulErrorDetail(lines)
|
||||
const msg = `sing-box 配置校验失败(code=${String(code)})${lines.length ? `:${lines.at(-1)}` : ''}${detail ? `\n\n${detail}` : ''}`
|
||||
this.log(msg, 'error')
|
||||
this.mainWindow?.webContents.send('singbox-error', msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
private runOnce(binPath: string, args: string[]): Promise<{ code: number, output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
const p = spawn(binPath, args, { windowsHide: true, env: this.getSingboxEnv() })
|
||||
const chunks: Buffer[] = []
|
||||
const onData = (d: any) => chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(String(d)))
|
||||
p.stdout?.on('data', onData)
|
||||
p.stderr?.on('data', onData)
|
||||
p.on('close', (code) => {
|
||||
resolve({ code: typeof code === 'number' ? code : -1, output: Buffer.concat(chunks).toString('utf8') })
|
||||
})
|
||||
p.on('error', () => {
|
||||
resolve({ code: -1, output: '无法执行 sing-box,请检查可执行文件是否完整' })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private buildHelpfulErrorDetail(lines: string[]): string {
|
||||
const text = lines.join('\n').toLowerCase()
|
||||
const hints: string[] = []
|
||||
console.log(text);
|
||||
|
||||
if (text.includes('create tun') || text.includes('tun') || text.includes('wintun') || text.includes('tunnel')) {
|
||||
hints.push('提示:当前使用 TUN 模式,Windows 下通常需要管理员权限,并且需要可用的 TUN/Wintun 支持。请尝试“以管理员身份运行”LagZero。')
|
||||
}
|
||||
|
||||
if (text.includes('access is denied') || text.includes('permission denied') || text.includes('operation not permitted')) {
|
||||
hints.push('提示:看起来是权限不足导致启动失败,请尝试“以管理员身份运行”。')
|
||||
}
|
||||
|
||||
if (text.includes('invalid') || text.includes('unknown field') || text.includes('parse') || text.includes('json')) {
|
||||
hints.push('提示:节点/配置参数可能不兼容当前 sing-box 版本,请把弹窗里的最后一行报错发我。')
|
||||
}
|
||||
|
||||
if (text.includes('deprecated_special_outbounds') || text.includes('deprecated special outbounds') || text.includes('enable_deprecated_special_outbounds')) {
|
||||
hints.push('提示:当前 sing-box 版本要求开启兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS=true。')
|
||||
}
|
||||
|
||||
if (text.includes('deprecated_tun_address_x') || text.includes('deprecated tun address x') || text.includes('enable_deprecated_tun_address_x')) {
|
||||
hints.push('提示:当前 sing-box 版本要求开启 TUN 兼容开关,我已在程序内自动注入环境变量 ENABLE_DEPRECATED_TUN_ADDRESS_X=true。')
|
||||
}
|
||||
|
||||
if (hints.length === 0) return ''
|
||||
return Array.from(new Set(hints)).join('\n')
|
||||
}
|
||||
|
||||
private getSingboxEnv(): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
ENABLE_DEPRECATED_SPECIAL_OUTBOUNDS: 'true',
|
||||
ENABLE_DEPRECATED_TUN_ADDRESS_X: 'true'
|
||||
}
|
||||
}
|
||||
|
||||
private stripAnsi(input: string): string {
|
||||
return input.replace(/\x1B\[[0-9;]*m/g, '')
|
||||
}
|
||||
|
||||
private async downloadAndInstallBinary(platform: NodeJS.Platform, arch: string): Promise<string> {
|
||||
const ext = platform === 'win32' ? '.exe' : ''
|
||||
const binPath = path.join(BIN_DIR, `sing-box${ext}`)
|
||||
|
||||
if (await fs.pathExists(binPath)) return binPath
|
||||
|
||||
this.log('未检测到 sing-box,开始自动下载...')
|
||||
|
||||
const { version, downloadUrl, archiveExt } = await this.resolveLatestReleaseDownload(platform, arch)
|
||||
|
||||
const archivePath = path.join(BIN_DIR, `sing-box-${version}${archiveExt}`)
|
||||
const extractDir = path.join(BIN_DIR, `tmp-${randomUUID()}`)
|
||||
|
||||
try {
|
||||
await fs.ensureDir(extractDir)
|
||||
await this.downloadToFile(downloadUrl, archivePath)
|
||||
await this.extractAndInstall(archivePath, archiveExt, extractDir, binPath, platform === 'win32')
|
||||
|
||||
if (!await fs.pathExists(binPath)) {
|
||||
throw new Error('安装完成后仍未找到 sing-box 可执行文件')
|
||||
}
|
||||
|
||||
this.log(`sing-box 已安装:${binPath}`)
|
||||
return binPath
|
||||
} catch (e: any) {
|
||||
const msg = `sing-box 自动下载/安装失败:${String(e?.message || e)}`
|
||||
this.log(msg, 'error')
|
||||
this.mainWindow?.webContents.send('singbox-error', msg)
|
||||
throw new Error(msg)
|
||||
} finally {
|
||||
await fs.remove(extractDir).catch(() => {})
|
||||
await fs.remove(archivePath).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveLatestReleaseDownload(platform: NodeJS.Platform, arch: string): Promise<{ version: string, downloadUrl: string, archiveExt: string }> {
|
||||
const platformName = platform === 'win32' ? 'windows'
|
||||
: platform === 'darwin' ? 'darwin'
|
||||
: platform === 'linux' ? 'linux'
|
||||
: null
|
||||
|
||||
if (!platformName) {
|
||||
throw new Error(`不支持的平台:${platform}`)
|
||||
}
|
||||
|
||||
const archName = arch === 'x64' ? 'amd64'
|
||||
: arch === 'arm64' ? 'arm64'
|
||||
: arch === 'ia32' ? '386'
|
||||
: null
|
||||
|
||||
if (!archName) {
|
||||
throw new Error(`不支持的架构:${arch}`)
|
||||
}
|
||||
|
||||
const archiveExt = platformName === 'windows' ? '.zip' : '.tar.gz'
|
||||
|
||||
const release = await this.fetchJson<any>('https://api.github.com/repos/SagerNet/sing-box/releases/latest')
|
||||
const tagName = String(release?.tag_name || '')
|
||||
const version = tagName.startsWith('v') ? tagName.slice(1) : tagName
|
||||
if (!version) throw new Error('无法解析 sing-box 版本号')
|
||||
|
||||
const assetName = `sing-box-${version}-${platformName}-${archName}${archiveExt}`
|
||||
const assets: any[] = Array.isArray(release?.assets) ? release.assets : []
|
||||
const asset = assets.find(a => String(a?.name || '') === assetName)
|
||||
const downloadUrl = String(asset?.browser_download_url || '')
|
||||
|
||||
if (!downloadUrl) {
|
||||
throw new Error(`未找到对应的发布包:${assetName}`)
|
||||
}
|
||||
|
||||
return { version, downloadUrl, archiveExt }
|
||||
}
|
||||
|
||||
private fetchJson<T>(url: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'LagZero',
|
||||
'Accept': 'application/vnd.github+json'
|
||||
}
|
||||
}, (res) => {
|
||||
const status = res.statusCode || 0
|
||||
const chunks: Buffer[] = []
|
||||
|
||||
res.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)))
|
||||
res.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8')
|
||||
if (status >= 300) {
|
||||
reject(new Error(`请求失败(${status}):${body.slice(0, 200)}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(body) as T)
|
||||
} catch (e) {
|
||||
reject(new Error('解析 JSON 失败'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
private async downloadToFile(url: string, destPath: string): Promise<void> {
|
||||
await fs.ensureDir(path.dirname(destPath))
|
||||
|
||||
const doRequest = (u: string, redirectLeft: number): Promise<void> => new Promise((resolve, reject) => {
|
||||
const req = https.request(u, {
|
||||
method: 'GET',
|
||||
headers: { 'User-Agent': 'LagZero' }
|
||||
}, (res) => {
|
||||
const status = res.statusCode || 0
|
||||
const location = res.headers.location
|
||||
if ([301, 302, 303, 307, 308].includes(status) && location && redirectLeft > 0) {
|
||||
res.resume()
|
||||
resolve(doRequest(location, redirectLeft - 1))
|
||||
return
|
||||
}
|
||||
if (status >= 300) {
|
||||
reject(new Error(`下载失败(${status})`))
|
||||
res.resume()
|
||||
return
|
||||
}
|
||||
|
||||
const total = Number(res.headers['content-length'] || 0)
|
||||
let received = 0
|
||||
let lastLoggedBucket = -1
|
||||
const fileStream = createWriteStream(destPath)
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
received += Buffer.byteLength(chunk)
|
||||
if (total > 0) {
|
||||
const pct = Math.floor((received / total) * 100)
|
||||
const bucket = Math.floor(pct / 20) * 20
|
||||
if (bucket !== lastLoggedBucket && bucket <= 100) {
|
||||
lastLoggedBucket = bucket
|
||||
this.log(`正在下载 sing-box:${bucket}%`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
pipeline(res as unknown as Readable, fileStream)
|
||||
.then(() => resolve())
|
||||
.catch(reject)
|
||||
})
|
||||
|
||||
req.on('error', reject)
|
||||
req.end()
|
||||
})
|
||||
|
||||
await doRequest(url, 5)
|
||||
}
|
||||
|
||||
private async extractAndInstall(
|
||||
archivePath: string,
|
||||
archiveExt: string,
|
||||
extractDir: string,
|
||||
binPath: string,
|
||||
isWindows: boolean
|
||||
): Promise<void> {
|
||||
if (archiveExt === '.zip') {
|
||||
const zip = new AdmZip(archivePath)
|
||||
zip.extractAllTo(extractDir, true)
|
||||
} else if (archiveExt === '.tar.gz') {
|
||||
await tar.x({ file: archivePath, cwd: extractDir })
|
||||
} else {
|
||||
throw new Error(`不支持的压缩格式:${archiveExt}`)
|
||||
}
|
||||
|
||||
const exeName = isWindows ? 'sing-box.exe' : 'sing-box'
|
||||
const exePath = await this.findFileRecursive(extractDir, exeName)
|
||||
if (!exePath) {
|
||||
throw new Error('解压后未找到 sing-box 可执行文件')
|
||||
}
|
||||
|
||||
await fs.copyFile(exePath, binPath)
|
||||
|
||||
if (isWindows) {
|
||||
const exeDir = path.dirname(exePath)
|
||||
const entries = await fs.readdir(exeDir, { withFileTypes: true })
|
||||
const dlls = entries
|
||||
.filter(e => e.isFile() && e.name.toLowerCase().endsWith('.dll'))
|
||||
.map(e => path.join(exeDir, e.name))
|
||||
|
||||
for (const dll of dlls) {
|
||||
const target = path.join(BIN_DIR, path.basename(dll))
|
||||
await fs.copyFile(dll, target)
|
||||
}
|
||||
}
|
||||
|
||||
await fs.chmod(binPath, 0o755).catch(() => {})
|
||||
}
|
||||
|
||||
private async findFileRecursive(rootDir: string, fileName: string): Promise<string | null> {
|
||||
const entries = await fs.readdir(rootDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const full = path.join(rootDir, entry.name)
|
||||
if (entry.isFile() && entry.name === fileName) return full
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const found = await this.findFileRecursive(path.join(rootDir, entry.name), fileName)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LagZero - Game Accelerator</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"name": "lagzero",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"main": "dist-electron/main.js",
|
||||
"type": "module",
|
||||
"author": "LagZero Team",
|
||||
"description": "Cross-platform Game Accelerator",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"pack": "electron-builder --dir",
|
||||
"dist": "electron-builder",
|
||||
"test": "vitest run --coverage"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.lagzero.client",
|
||||
"productName": "LagZero",
|
||||
"directories": {
|
||||
"output": "release/${version}"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"dist-electron"
|
||||
],
|
||||
"asar": true,
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-Setup-${version}-${os}-${arch}.${ext}"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": true
|
||||
},
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"hardenedRuntime": true,
|
||||
"gatekeeperAssess": false
|
||||
},
|
||||
"linux": {
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
"rpm",
|
||||
"pacman"
|
||||
],
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"category": "Utility"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@vueuse/core": "^14.2.0",
|
||||
"adm-zip": "^0.5.16",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"echarts": "^6.0.0",
|
||||
"electron-updater": "^6.7.3",
|
||||
"fs-extra": "^11.3.3",
|
||||
"js-base64": "^3.7.8",
|
||||
"kysely": "^0.28.11",
|
||||
"pinia": "^3.0.4",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tar": "^7.5.7",
|
||||
"uuid": "^13.0.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^5.0.2",
|
||||
"vuedraggable": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iconify-json/carbon": "^1.2.18",
|
||||
"@iconify-json/material-symbols": "^1.2.54",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@unocss/reset": "^66.6.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"electron": "^40.2.1",
|
||||
"electron-builder": "^26.7.0",
|
||||
"happy-dom": "^20.5.3",
|
||||
"naive-ui": "^2.43.2",
|
||||
"sass": "^1.97.3",
|
||||
"typescript": "~5.9.3",
|
||||
"unocss": "^66.6.0",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vfonts": "^0.0.3",
|
||||
"vite": "^7.2.4",
|
||||
"vite-plugin-electron": "^0.29.0",
|
||||
"vite-plugin-electron-renderer": "^0.14.6",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.1.4"
|
||||
}
|
||||
}
|
||||
Generated
+5765
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="boltGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#4ade80;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#22c55e;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="15" result="coloredBlur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Background Circle (Optional, can be removed for transparent logo) -->
|
||||
<circle cx="256" cy="256" r="240" fill="#1e293b" stroke="#334155" stroke-width="10" />
|
||||
|
||||
<!-- Lightning Bolt Shape -->
|
||||
<path d="M280 40 L160 260 L260 260 L230 460 L380 220 L270 220 L320 40 Z"
|
||||
fill="url(#boltGradient)"
|
||||
stroke="#ffffff"
|
||||
stroke-width="5"
|
||||
filter="url(#glow)"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Speed Lines -->
|
||||
<path d="M100 150 L60 180 M90 200 L50 230 M100 250 L60 280"
|
||||
stroke="#4ade80"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
+77
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<n-config-provider :theme="theme" :theme-overrides="themeOverrides">
|
||||
<n-message-provider>
|
||||
<n-dialog-provider>
|
||||
<n-notification-provider>
|
||||
<n-modal-provider>
|
||||
<router-view />
|
||||
</n-modal-provider>
|
||||
</n-notification-provider>
|
||||
</n-dialog-provider>
|
||||
</n-message-provider>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { darkTheme, type GlobalThemeOverrides } from 'naive-ui'
|
||||
import { useTheme, type ThemeColor } from './composables/useTheme'
|
||||
|
||||
const { isDark, themeColor } = useTheme()
|
||||
|
||||
const theme = computed(() => isDark.value ? darkTheme : null)
|
||||
|
||||
const themeColors: Record<ThemeColor, { primary: string; hover: string; pressed: string }> = {
|
||||
green: { primary: '#3eaf7c', hover: '#10b981', pressed: '#047857' },
|
||||
blue: { primary: '#3b82f6', hover: '#2563eb', pressed: '#1d4ed8' },
|
||||
purple: { primary: '#8b5cf6', hover: '#7c3aed', pressed: '#6d28d9' },
|
||||
orange: { primary: '#f97316', hover: '#ea580c', pressed: '#c2410c' },
|
||||
red: { primary: '#ef4444', hover: '#dc2626', pressed: '#b91c1c' },
|
||||
}
|
||||
|
||||
const themeOverrides = computed<GlobalThemeOverrides>(() => {
|
||||
const color = themeColors[themeColor.value] || themeColors.green
|
||||
|
||||
const common = {
|
||||
primaryColor: color.primary,
|
||||
primaryColorHover: color.hover,
|
||||
primaryColorPressed: color.pressed,
|
||||
primaryColorSuppl: color.hover,
|
||||
}
|
||||
|
||||
return {
|
||||
common,
|
||||
Button: {
|
||||
textColorPrimary: '#ffffff',
|
||||
textColorHoverPrimary: '#ffffff',
|
||||
textColorPressedPrimary: '#ffffff',
|
||||
textColorFocusPrimary: '#ffffff',
|
||||
textColorDisabledPrimary: '#ffffff',
|
||||
colorPrimary: color.primary,
|
||||
colorHoverPrimary: color.hover,
|
||||
colorPressedPrimary: color.pressed,
|
||||
colorFocusPrimary: color.hover,
|
||||
colorDisabledPrimary: color.primary, // Use opacity for disabled state usually handled by component, but color needs to be set
|
||||
borderPrimary: `1px solid ${color.primary}`,
|
||||
borderHoverPrimary: `1px solid ${color.hover}`,
|
||||
borderPressedPrimary: `1px solid ${color.pressed}`,
|
||||
borderFocusPrimary: `1px solid ${color.hover}`,
|
||||
borderDisabledPrimary: `1px solid ${color.primary}`,
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-on-surface);
|
||||
font-family: 'Noto Sans SC', 'Source Han Sans SC', system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
</style>
|
||||
Vendored
+77
@@ -0,0 +1,77 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue').EffectScope
|
||||
const computed: typeof import('vue').computed
|
||||
const createApp: typeof import('vue').createApp
|
||||
const customRef: typeof import('vue').customRef
|
||||
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
|
||||
const defineComponent: typeof import('vue').defineComponent
|
||||
const effectScope: typeof import('vue').effectScope
|
||||
const getCurrentInstance: typeof import('vue').getCurrentInstance
|
||||
const getCurrentScope: typeof import('vue').getCurrentScope
|
||||
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
|
||||
const h: typeof import('vue').h
|
||||
const inject: typeof import('vue').inject
|
||||
const isProxy: typeof import('vue').isProxy
|
||||
const isReactive: typeof import('vue').isReactive
|
||||
const isReadonly: typeof import('vue').isReadonly
|
||||
const isRef: typeof import('vue').isRef
|
||||
const isShallow: typeof import('vue').isShallow
|
||||
const markRaw: typeof import('vue').markRaw
|
||||
const nextTick: typeof import('vue').nextTick
|
||||
const onActivated: typeof import('vue').onActivated
|
||||
const onBeforeMount: typeof import('vue').onBeforeMount
|
||||
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
|
||||
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
|
||||
const onDeactivated: typeof import('vue').onDeactivated
|
||||
const onErrorCaptured: typeof import('vue').onErrorCaptured
|
||||
const onMounted: typeof import('vue').onMounted
|
||||
const onRenderTracked: typeof import('vue').onRenderTracked
|
||||
const onRenderTriggered: typeof import('vue').onRenderTriggered
|
||||
const onScopeDispose: typeof import('vue').onScopeDispose
|
||||
const onServerPrefetch: typeof import('vue').onServerPrefetch
|
||||
const onUnmounted: typeof import('vue').onUnmounted
|
||||
const onUpdated: typeof import('vue').onUpdated
|
||||
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
|
||||
const provide: typeof import('vue').provide
|
||||
const reactive: typeof import('vue').reactive
|
||||
const readonly: typeof import('vue').readonly
|
||||
const ref: typeof import('vue').ref
|
||||
const resolveComponent: typeof import('vue').resolveComponent
|
||||
const shallowReactive: typeof import('vue').shallowReactive
|
||||
const shallowReadonly: typeof import('vue').shallowReadonly
|
||||
const shallowRef: typeof import('vue').shallowRef
|
||||
const toRaw: typeof import('vue').toRaw
|
||||
const toRef: typeof import('vue').toRef
|
||||
const toRefs: typeof import('vue').toRefs
|
||||
const toValue: typeof import('vue').toValue
|
||||
const triggerRef: typeof import('vue').triggerRef
|
||||
const unref: typeof import('vue').unref
|
||||
const useAttrs: typeof import('vue').useAttrs
|
||||
const useCssModule: typeof import('vue').useCssModule
|
||||
const useCssVars: typeof import('vue').useCssVars
|
||||
const useDialog: typeof import('naive-ui').useDialog
|
||||
const useId: typeof import('vue').useId
|
||||
const useLoadingBar: typeof import('naive-ui').useLoadingBar
|
||||
const useMessage: typeof import('naive-ui').useMessage
|
||||
const useModel: typeof import('vue').useModel
|
||||
const useNotification: typeof import('naive-ui').useNotification
|
||||
const useSlots: typeof import('vue').useSlots
|
||||
const useTemplateRef: typeof import('vue').useTemplateRef
|
||||
const watch: typeof import('vue').watch
|
||||
const watchEffect: typeof import('vue').watchEffect
|
||||
const watchPostEffect: typeof import('vue').watchPostEffect
|
||||
const watchSyncEffect: typeof import('vue').watchSyncEffect
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AdvancedConfigEditor: typeof import('./components/library/AdvancedConfigEditor.vue')['default']
|
||||
CategoryManager: typeof import('./components/library/CategoryManager.vue')['default']
|
||||
GameEditModal: typeof import('./components/library/GameEditModal.vue')['default']
|
||||
LatencyChart: typeof import('./components/dashboard/LatencyChart.vue')['default']
|
||||
NButton: typeof import('naive-ui')['NButton']
|
||||
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
|
||||
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
|
||||
NForm: typeof import('naive-ui')['NForm']
|
||||
NFormItem: typeof import('naive-ui')['NFormItem']
|
||||
NInput: typeof import('naive-ui')['NInput']
|
||||
NInputNumber: typeof import('naive-ui')['NInputNumber']
|
||||
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
|
||||
NModal: typeof import('naive-ui')['NModal']
|
||||
NModalProvider: typeof import('naive-ui')['NModalProvider']
|
||||
NNotificationProvider: typeof import('naive-ui')['NNotificationProvider']
|
||||
NodeCard: typeof import('./components/node/NodeCard.vue')['default']
|
||||
NodeEditModal: typeof import('./components/node/NodeEditModal.vue')['default']
|
||||
NodeImportModal: typeof import('./components/node/NodeImportModal.vue')['default']
|
||||
NodeManager: typeof import('./components/node/NodeManager.vue')['default']
|
||||
NodeSelector: typeof import('./components/node/NodeSelector.vue')['default']
|
||||
NRadio: typeof import('naive-ui')['NRadio']
|
||||
NRadioGroup: typeof import('naive-ui')['NRadioGroup']
|
||||
NSelect: typeof import('naive-ui')['NSelect']
|
||||
NSpace: typeof import('naive-ui')['NSpace']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
ThemeToggle: typeof import('./components/common/ThemeToggle.vue')['default']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<button @click="toggleTheme"
|
||||
class="relative w-7 h-7 flex items-center justify-center rounded hover:bg-on-surface/10 transition-colors overflow-hidden group focus:outline-none"
|
||||
:title="$t('settings.toggle_theme')" :aria-label="$t('settings.toggle_theme')">
|
||||
<div class="relative w-5 h-5">
|
||||
<div
|
||||
class="absolute inset-0 transform transition-transform duration-300 ease-spring flex items-center justify-center text-on-surface-variant group-hover:text-on-surface"
|
||||
:class="resolvedTheme === 'dark' ? 'rotate-0 opacity-100' : 'rotate-90 opacity-0'">
|
||||
<div class="i-material-symbols-dark-mode text-lg"></div>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 transform transition-transform duration-300 ease-spring flex items-center justify-center text-on-surface-variant group-hover:text-on-surface"
|
||||
:class="resolvedTheme === 'light' ? 'rotate-0 opacity-100' : '-rotate-90 opacity-0'">
|
||||
<div class="i-material-symbols-light-mode text-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
|
||||
const { resolvedTheme, toggleTheme } = useTheme()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ease-spring {
|
||||
transition-timing-function: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div ref="chartRef" class="w-full h-64"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const chartRef = ref<HTMLElement>()
|
||||
let chart: echarts.ECharts | null = null
|
||||
|
||||
onMounted(() => {
|
||||
if (chartRef.value) {
|
||||
chart = echarts.init(chartRef.value)
|
||||
|
||||
// Mock data generation
|
||||
const data = Array.from({ length: 20 }, () => Math.floor(Math.random() * 100))
|
||||
const xData = Array.from({ length: 20 }, (_, i) => i)
|
||||
|
||||
const option = {
|
||||
backgroundColor: 'transparent',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'cross' }
|
||||
},
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
left: 40,
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xData,
|
||||
axisLine: { lineStyle: { color: '#666' } }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitLine: { lineStyle: { color: '#333' } },
|
||||
axisLine: { lineStyle: { color: '#666' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: data,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(62, 175, 124, 0.5)' },
|
||||
{ offset: 1, color: 'rgba(62, 175, 124, 0.0)' }
|
||||
])
|
||||
},
|
||||
itemStyle: { color: '#3eaf7c' }
|
||||
}
|
||||
]
|
||||
}
|
||||
chart.setOption(option)
|
||||
|
||||
window.addEventListener('resize', resize)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', resize)
|
||||
chart?.dispose()
|
||||
})
|
||||
|
||||
const resize = () => chart?.resize()
|
||||
</script>
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="modelValue"
|
||||
@update:show="$emit('update:modelValue', $event)"
|
||||
preset="card"
|
||||
:title="isEdit ? $t('common.edit') : $t('games.add_game')"
|
||||
class="w-[600px]"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<n-form
|
||||
ref="formRef"
|
||||
label-placement="top"
|
||||
:model="form"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<n-form-item :label="$t('games.name')">
|
||||
<n-input
|
||||
v-model:value="form.name"
|
||||
placeholder="例如:英雄联盟"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$t('games.category')">
|
||||
<n-select
|
||||
v-model:value="form.category"
|
||||
:options="categoryStore.categories.map(c => ({ label: c.name, value: c.id }))"
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
|
||||
<n-form-item :label="$t('games.mode')">
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<n-radio-group v-model:value="form.proxyMode">
|
||||
<n-space>
|
||||
<n-radio value="process">{{ $t('games.mode_process') }}</n-radio>
|
||||
<n-radio value="routing">{{ $t('games.mode_routing') }}</n-radio>
|
||||
</n-space>
|
||||
</n-radio-group>
|
||||
<span class="text-xs text-on-surface-muted">
|
||||
{{ form.proxyMode === 'process' ? $t('games.mode_process_desc') : $t('games.mode_routing_desc') }}
|
||||
</span>
|
||||
</div>
|
||||
</n-form-item>
|
||||
|
||||
<template v-if="form.proxyMode === 'process'">
|
||||
<n-form-item :label="$t('games.process_name')">
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div v-for="(_, index) in processList" :key="index" class="flex gap-2">
|
||||
<n-input
|
||||
v-model:value="processList[index]"
|
||||
placeholder="例如:League of Legends.exe"
|
||||
/>
|
||||
<n-button
|
||||
v-if="processList.length > 1"
|
||||
@click="removeProcess(index)"
|
||||
type="error"
|
||||
quaternary
|
||||
circle
|
||||
>
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-delete"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="flex gap-2 self-start">
|
||||
<n-button @click="addProcess" dashed size="small">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-add"></div>
|
||||
</template>
|
||||
{{ $t('games.add_process') }}
|
||||
</n-button>
|
||||
<n-button @click="pickProcess" dashed size="small">
|
||||
选择文件
|
||||
</n-button>
|
||||
<n-button @click="pickProcessFolder" dashed size="small">
|
||||
选择目录
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.proxyMode === 'routing'">
|
||||
<n-form-item :label="$t('rules.ip_rules')">
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<n-input
|
||||
v-model:value="routingRulesText"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
class="font-mono"
|
||||
placeholder="1.1.1.1/32 8.8.8.8"
|
||||
/>
|
||||
<span class="text-xs text-on-surface-muted">{{ $t('games.process_name_tip') }} (CIDR/IP)</span>
|
||||
</div>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item :label="`${$t('games.process_name')} (${$t('common.optional')})`">
|
||||
<div class="flex flex-col w-full gap-2">
|
||||
<div v-for="(_, index) in processList" :key="index" class="flex gap-2">
|
||||
<n-input
|
||||
v-model:value="processList[index]"
|
||||
placeholder="例如:Game.exe(用于启动检测)"
|
||||
/>
|
||||
<n-button
|
||||
v-if="processList.length > 1"
|
||||
@click="removeProcess(index)"
|
||||
type="error"
|
||||
quaternary
|
||||
circle
|
||||
>
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-delete"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="flex gap-2 self-start">
|
||||
<n-button @click="addProcess" dashed size="small">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-add"></div>
|
||||
</template>
|
||||
{{ $t('games.add_process') }}
|
||||
</n-button>
|
||||
<n-button @click="pickProcess" dashed size="small">
|
||||
选择文件
|
||||
</n-button>
|
||||
<n-button @click="pickProcessFolder" dashed size="small">
|
||||
选择目录
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<n-form-item :label="$t('games.icon')">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<div class="w-10 h-10 bg-surface rounded border border-border overflow-hidden flex items-center justify-center">
|
||||
<img v-if="form.iconUrl" :src="form.iconUrl" class="w-full h-full object-cover" />
|
||||
<div v-else class="i-material-symbols-image text-xl text-on-surface-muted"></div>
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="form.iconUrl"
|
||||
placeholder="支持 URL 或 file://..."
|
||||
/>
|
||||
<n-button @click="pickImage">选择文件</n-button>
|
||||
<n-button quaternary @click="form.iconUrl = ''">清空</n-button>
|
||||
</div>
|
||||
</n-form-item>
|
||||
|
||||
</n-form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="close">{{ $t('common.cancel') }}</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="save"
|
||||
>
|
||||
{{ $t('common.save') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, h } from 'vue'
|
||||
import { useMessage, useDialog, NInputNumber } from 'naive-ui'
|
||||
import type { Game } from '@/types'
|
||||
import { useCategoryStore } from '@/stores/categories'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
editingGame?: Game | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'save', game: Game): void
|
||||
}>()
|
||||
|
||||
const categoryStore = useCategoryStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const isEdit = computed(() => !!props.editingGame)
|
||||
|
||||
const form = ref<Partial<Game>>({
|
||||
name: '',
|
||||
iconUrl: '',
|
||||
processName: [],
|
||||
category: '',
|
||||
proxyMode: 'process',
|
||||
routingRules: []
|
||||
})
|
||||
|
||||
const processList = ref<string[]>([''])
|
||||
const routingRulesText = ref('')
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
if (props.editingGame) {
|
||||
form.value = JSON.parse(JSON.stringify(props.editingGame))
|
||||
|
||||
if (Array.isArray(props.editingGame.processName)) {
|
||||
processList.value = [...props.editingGame.processName]
|
||||
} else if (props.editingGame.processName) {
|
||||
processList.value = [props.editingGame.processName]
|
||||
} else {
|
||||
processList.value = ['']
|
||||
}
|
||||
|
||||
if (props.editingGame.routingRules) {
|
||||
routingRulesText.value = props.editingGame.routingRules.join('\n')
|
||||
} else {
|
||||
routingRulesText.value = ''
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
name: '',
|
||||
iconUrl: '',
|
||||
processName: [],
|
||||
category: categoryStore.categories[0]?.id || '',
|
||||
proxyMode: 'process',
|
||||
routingRules: []
|
||||
}
|
||||
processList.value = ['']
|
||||
routingRulesText.value = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function addProcess() {
|
||||
processList.value.push('')
|
||||
}
|
||||
|
||||
function removeProcess(index: number) {
|
||||
processList.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function pickImage() {
|
||||
// @ts-ignore
|
||||
const picker = window.electron?.pickImage
|
||||
if (!picker) {
|
||||
message.error('当前环境不支持选择文件')
|
||||
return
|
||||
}
|
||||
const url = await picker()
|
||||
if (url) {
|
||||
form.value.iconUrl = url
|
||||
}
|
||||
}
|
||||
|
||||
async function pickProcess() {
|
||||
const picker = window.electron?.pickProcess
|
||||
if (!picker) {
|
||||
message.error('当前环境不支持选择文件')
|
||||
return
|
||||
}
|
||||
const files = await picker()
|
||||
if (files && files.length > 0) {
|
||||
// If we have existing empty slots, fill them first
|
||||
// Or just append unique ones
|
||||
const current = new Set(processList.value.filter(Boolean))
|
||||
files.forEach(f => current.add(f))
|
||||
processList.value = Array.from(current)
|
||||
if (processList.value.length === 0) processList.value.push('')
|
||||
}
|
||||
}
|
||||
|
||||
async function pickProcessFolder() {
|
||||
const picker = window.electron?.pickProcessFolder
|
||||
if (!picker) {
|
||||
message.error('当前环境不支持选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
const depth = ref(1)
|
||||
dialog.create({
|
||||
title: '选择目录扫描深度',
|
||||
content: () => h('div', { class: 'flex flex-col gap-2' }, [
|
||||
h('span', '请输入扫描深度(1 表示仅当前目录,-1 表示无限递归):'),
|
||||
h(NInputNumber, {
|
||||
value: depth.value,
|
||||
onUpdateValue: (v: number | null) => { if (v !== null) depth.value = v },
|
||||
min: -1
|
||||
})
|
||||
]),
|
||||
positiveText: '确定选择目录',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
const files = await picker(depth.value)
|
||||
if (files && files.length > 0) {
|
||||
const current = new Set(processList.value.filter(Boolean))
|
||||
files.forEach(f => current.add(f))
|
||||
processList.value = Array.from(current)
|
||||
if (processList.value.length === 0) processList.value.push('')
|
||||
message.success(`已添加 ${files.length} 个可执行文件`)
|
||||
} else if (files) {
|
||||
message.info('该目录下未找到 exe 文件')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!form.value.name) return
|
||||
|
||||
const cleanProcesses = processList.value.map(p => p.trim()).filter(Boolean)
|
||||
|
||||
const cleanRules = routingRulesText.value.split('\n').map(r => r.trim()).filter(Boolean)
|
||||
|
||||
const gameData: Game = {
|
||||
...form.value,
|
||||
iconUrl: form.value.iconUrl ? String(form.value.iconUrl).trim() : undefined,
|
||||
processName: cleanProcesses,
|
||||
routingRules: cleanRules
|
||||
} as Game
|
||||
|
||||
if (props.editingGame) {
|
||||
gameData.id = props.editingGame.id
|
||||
}
|
||||
|
||||
emit('save', gameData)
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="modelValue"
|
||||
@update:show="$emit('update:modelValue', $event)"
|
||||
preset="card"
|
||||
:title="$t('common.categories')"
|
||||
class="w-[600px]"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<n-input
|
||||
v-model:value="newCategoryName"
|
||||
:placeholder="$t('categories.new_category_placeholder')"
|
||||
@keyup.enter="addCategory"
|
||||
/>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="addCategory"
|
||||
:disabled="!newCategoryName"
|
||||
>
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-add"></div>
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto min-h-[100px]">
|
||||
<draggable
|
||||
v-model="categoryStore.categories"
|
||||
item-key="id"
|
||||
handle=".drag-handle"
|
||||
@end="saveOrder"
|
||||
class="space-y-2"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div class="flex items-center gap-3 p-3 bg-surface rounded border border-border group hover:border-primary/50 transition">
|
||||
<div class="drag-handle text-on-surface-muted i-material-symbols-drag-indicator hover:text-on-surface cursor-grab active:cursor-grabbing"></div>
|
||||
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<div v-if="editingId === element.id" class="flex-1">
|
||||
<n-input
|
||||
v-model:value="element.name"
|
||||
size="small"
|
||||
@blur="finishEdit(element)"
|
||||
@keyup.enter="finishEdit(element)"
|
||||
ref="editInput"
|
||||
v-focus
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex-1 font-bold text-on-surface" @dblclick="startEdit(element)">
|
||||
{{ element.name }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<n-button size="small" quaternary circle @click="startEdit(element)">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-edit"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button size="small" quaternary circle type="error" @click="deleteCategory(element)">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-delete"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useCategoryStore } from '@/stores/categories'
|
||||
import type { Category } from '@/types'
|
||||
import { useMessage, useDialog } from 'naive-ui'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const categoryStore = useCategoryStore()
|
||||
const newCategoryName = ref('')
|
||||
const editingId = ref<string | null>(null)
|
||||
|
||||
// Custom directive for focus
|
||||
const vFocus = {
|
||||
mounted: (el: HTMLElement) => {
|
||||
// Naive UI Input wrapper
|
||||
const input = el.querySelector('input')
|
||||
if (input) input.focus()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function addCategory() {
|
||||
if (!newCategoryName.value) return
|
||||
|
||||
await categoryStore.addCategory({
|
||||
id: Date.now().toString(36),
|
||||
name: newCategoryName.value,
|
||||
order: categoryStore.categories.length
|
||||
})
|
||||
|
||||
newCategoryName.value = ''
|
||||
message.success('Category added')
|
||||
}
|
||||
|
||||
function startEdit(category: Category) {
|
||||
editingId.value = category.id
|
||||
}
|
||||
|
||||
async function finishEdit(category: Category) {
|
||||
if (!editingId.value) return
|
||||
|
||||
await categoryStore.updateCategory(category)
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function deleteCategory(category: Category) {
|
||||
dialog.warning({
|
||||
title: 'Delete Category',
|
||||
content: `Are you sure you want to delete "${category.name}"?`,
|
||||
positiveText: t('common.delete'),
|
||||
negativeText: t('common.cancel'),
|
||||
onPositiveClick: async () => {
|
||||
await categoryStore.removeCategory(category.id)
|
||||
message.success('Category deleted')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function saveOrder() {
|
||||
// Order is already saved when updated via removeCategory/updateCategory/addCategory
|
||||
// If drag and drop only updates the store state but not the backend, we need a method to persist.
|
||||
// Assuming updateCategory handles it per item or we need a bulk save.
|
||||
// Current store only has add/update/remove.
|
||||
// Let's iterate and update for now as the store doesn't have bulk save.
|
||||
const updates = categoryStore.categories.map((c, index) => ({
|
||||
...c,
|
||||
order: index
|
||||
}))
|
||||
|
||||
for (const c of updates) {
|
||||
await categoryStore.updateCategory(c)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="modelValue"
|
||||
@update:show="$emit('update:modelValue', $event)"
|
||||
preset="card"
|
||||
:title="isEdit ? $t('common.edit') : $t('games.add_game')"
|
||||
class="w-[500px]"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<n-form
|
||||
ref="formRef"
|
||||
label-placement="top"
|
||||
:model="form"
|
||||
>
|
||||
<n-form-item :label="$t('games.name')">
|
||||
<n-input
|
||||
v-model:value="form.name"
|
||||
placeholder="例如:英雄联盟"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item :label="$t('games.process_name')">
|
||||
<div class="flex flex-col w-full gap-1">
|
||||
<n-input
|
||||
v-model:value="processNames"
|
||||
placeholder="例如:League of Legends.exe, LeagueClient.exe"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<n-button @click="pickProcess" size="small" dashed>选择文件</n-button>
|
||||
<n-button @click="pickProcessFolder" size="small" dashed>选择目录</n-button>
|
||||
</div>
|
||||
<span class="text-xs text-on-surface-muted">{{ $t('games.process_name_tip') }}</span>
|
||||
</div>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item :label="$t('games.icon')">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<div class="w-10 h-10 bg-surface rounded border border-border overflow-hidden flex items-center justify-center">
|
||||
<img v-if="form.iconUrl" :src="form.iconUrl" class="w-full h-full object-cover" />
|
||||
<div v-else class="i-material-symbols-image text-xl text-on-surface-muted"></div>
|
||||
</div>
|
||||
<n-input v-model:value="form.iconUrl" placeholder="支持 URL 或 file://..." />
|
||||
<n-button @click="pickImage">选择文件</n-button>
|
||||
<n-button quaternary @click="form.iconUrl = ''">清空</n-button>
|
||||
</div>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item :label="$t('games.category')">
|
||||
<n-radio-group v-model:value="form.category">
|
||||
<n-radio-button
|
||||
v-for="cat in categories"
|
||||
:key="cat"
|
||||
:value="cat"
|
||||
:label="cat.toUpperCase()"
|
||||
/>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="close">{{ $t('common.cancel') }}</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="save"
|
||||
:disabled="!form.name || !processNames"
|
||||
>
|
||||
{{ $t('common.save') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed, h } from 'vue'
|
||||
import { useMessage, useDialog, NInputNumber } from 'naive-ui'
|
||||
import type { Game } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
editingGame?: Game | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'save', game: Omit<Game, 'id'> | Game): void
|
||||
}>()
|
||||
|
||||
const isEdit = computed(() => !!props.editingGame)
|
||||
|
||||
const categories: Game['category'][] = ['fps', 'moba', 'rpg', 'other']
|
||||
|
||||
const form = ref<Omit<Game, 'id'>>({
|
||||
name: '',
|
||||
iconUrl: '',
|
||||
processName: '',
|
||||
category: 'other',
|
||||
lastPlayed: 0
|
||||
})
|
||||
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const processNames = computed({
|
||||
get: () => {
|
||||
if (Array.isArray(form.value.processName)) {
|
||||
return form.value.processName.join(', ')
|
||||
}
|
||||
return form.value.processName || ''
|
||||
},
|
||||
set: (val: string) => {
|
||||
const names = val.split(/[,,]/).map(s => s.trim()).filter(Boolean)
|
||||
form.value.processName = names.length > 1 ? names : (names[0] || '')
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
if (props.editingGame) {
|
||||
form.value = JSON.parse(JSON.stringify(props.editingGame))
|
||||
} else {
|
||||
form.value = {
|
||||
name: '',
|
||||
iconUrl: '',
|
||||
processName: '',
|
||||
category: 'other',
|
||||
lastPlayed: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
async function pickImage() {
|
||||
// @ts-ignore
|
||||
const picker = window.electron?.pickImage
|
||||
if (!picker) {
|
||||
message.error('当前环境不支持选择文件')
|
||||
return
|
||||
}
|
||||
const url = await picker()
|
||||
if (url) {
|
||||
form.value.iconUrl = url
|
||||
}
|
||||
}
|
||||
|
||||
async function pickProcess() {
|
||||
const picker = window.electron?.pickProcess
|
||||
if (!picker) {
|
||||
message.error('当前环境不支持选择文件')
|
||||
return
|
||||
}
|
||||
const files = await picker()
|
||||
if (files && files.length > 0) {
|
||||
let current: string[] = []
|
||||
if (Array.isArray(form.value.processName)) {
|
||||
current = [...form.value.processName]
|
||||
} else if (form.value.processName) {
|
||||
current = [form.value.processName]
|
||||
}
|
||||
const set = new Set(current)
|
||||
files.forEach(f => set.add(f))
|
||||
form.value.processName = Array.from(set)
|
||||
}
|
||||
}
|
||||
|
||||
async function pickProcessFolder() {
|
||||
const picker = window.electron?.pickProcessFolder
|
||||
if (!picker) {
|
||||
message.error('当前环境不支持选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// Ask for depth
|
||||
const depth = ref(1)
|
||||
dialog.create({
|
||||
title: '选择目录扫描深度',
|
||||
content: () => h('div', { class: 'flex flex-col gap-2' }, [
|
||||
h('span', '请输入扫描深度(1 表示仅当前目录,-1 表示无限递归):'),
|
||||
h(NInputNumber, {
|
||||
value: depth.value,
|
||||
onUpdateValue: (v: number | null) => { if (v !== null) depth.value = v },
|
||||
min: -1
|
||||
})
|
||||
]),
|
||||
positiveText: '确定选择目录',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
const files = await picker(depth.value)
|
||||
if (files && files.length > 0) {
|
||||
let current: string[] = []
|
||||
if (Array.isArray(form.value.processName)) {
|
||||
current = [...form.value.processName]
|
||||
} else if (form.value.processName) {
|
||||
current = [form.value.processName]
|
||||
}
|
||||
const set = new Set(current)
|
||||
files.forEach(f => set.add(f))
|
||||
form.value.processName = Array.from(set)
|
||||
message.success(`已添加 ${files.length} 个可执行文件`)
|
||||
} else if (files) {
|
||||
message.info('该目录下未找到 exe 文件')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!form.value.name) return
|
||||
|
||||
if (props.editingGame) {
|
||||
emit('save', { ...form.value, id: props.editingGame.id })
|
||||
} else {
|
||||
emit('save', form.value)
|
||||
}
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div @click="$emit('select', node)"
|
||||
class="group relative bg-surface-panel rounded-xl border transition-all duration-300 cursor-pointer overflow-hidden hover:bg-surface/10"
|
||||
:class="[
|
||||
selected
|
||||
? 'border-primary shadow-[0_0_15px_rgba(var(--primary-rgb),0.15)] bg-primary/5'
|
||||
: 'border-border hover:border-primary/30'
|
||||
]">
|
||||
|
||||
<!-- Hover Highlight Effect -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
</div>
|
||||
|
||||
<div class="p-3 flex flex-col gap-2 relative z-10" :class="{ 'p-4 gap-3': !compact }">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-2.5 overflow-hidden">
|
||||
<!-- Flag/Icon -->
|
||||
<div
|
||||
class="w-8 h-8 md:w-9 md:h-9 rounded-xl bg-surface flex items-center justify-center shrink-0 border border-border group-hover:border-primary/30 transition-colors shadow-sm">
|
||||
<div :class="getNodeIcon(node.type)"
|
||||
class="text-base md:text-lg text-on-surface-variant group-hover:text-primary transition-colors">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col min-w-0">
|
||||
<div
|
||||
class="font-bold text-xs md:text-sm text-on-surface truncate leading-tight group-hover:text-primary transition-colors">
|
||||
{{ node.tag || node.server }}</div>
|
||||
<div class="flex items-center gap-1.5 text-[9px] md:text-[10px] text-on-surface-muted mt-0.5">
|
||||
<span class="uppercase bg-surface/50 px-1 py-0 rounded border border-border">{{ node.type
|
||||
}}</span>
|
||||
<span class="truncate opacity-70">{{ node.server }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selection Indicator -->
|
||||
<div v-if="selected" class="shrink-0">
|
||||
<div class="i-material-symbols-check-circle text-primary text-base md:text-lg animate-scale-in">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-3 gap-1.5 md:gap-2">
|
||||
<!-- Latency -->
|
||||
<div
|
||||
class="bg-surface/40 rounded-lg py-1 px-1.5 flex flex-col items-center justify-center border border-border/50 group-hover:border-primary/20 transition-colors">
|
||||
<div class="text-[8px] md:text-[9px] uppercase font-bold text-on-surface-muted mb-0.5">{{
|
||||
$t('common.latency_short') }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="w-1 md:w-1.5 h-1 md:h-1.5 rounded-full" :class="getLatencyColor(node.latency)">
|
||||
</div>
|
||||
<span class="font-mono font-bold text-[10px] md:text-xs"
|
||||
:class="getLatencyTextColor(node.latency)">
|
||||
{{ node.latency > 0 ? node.latency : '-' }}
|
||||
</span>
|
||||
<span class="text-[8px] md:text-[9px] text-on-surface-muted">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load -->
|
||||
<div
|
||||
class="bg-surface/40 rounded-lg py-1 px-1.5 flex flex-col items-center justify-center border border-border/50 group-hover:border-primary/20 transition-colors">
|
||||
<div class="text-[8px] md:text-[9px] uppercase font-bold text-on-surface-muted mb-0.5">{{
|
||||
$t('common.load') }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="i-material-symbols-analytics text-[9px] md:text-[10px] text-on-surface-muted"></div>
|
||||
<span class="font-mono font-bold text-[10px] md:text-xs text-on-surface">{{ node.load || '-'
|
||||
}}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bandwidth -->
|
||||
<div
|
||||
class="bg-surface/40 rounded-lg py-1 px-1.5 flex flex-col items-center justify-center border border-border/50 group-hover:border-primary/20 transition-colors">
|
||||
<div class="text-[8px] md:text-[9px] uppercase font-bold text-on-surface-muted mb-0.5">{{
|
||||
$t('common.speed') }}</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="i-material-symbols-speed text-[9px] md:text-[10px] text-on-surface-muted"></div>
|
||||
<span class="font-mono font-bold text-[10px] md:text-xs text-on-surface">
|
||||
{{ node.bandwidth ? (node.bandwidth / 1024 / 1024).toFixed(1) : '-' }}
|
||||
</span>
|
||||
<span class="text-[8px] md:text-[9px] text-on-surface-muted">MB/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
defineProps<{
|
||||
node: any
|
||||
selected: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'select', node: any): void
|
||||
}>()
|
||||
|
||||
function getNodeIcon(type: string) {
|
||||
switch (type) {
|
||||
case 'vmess': return 'i-carbon-network-4'
|
||||
case 'vless': return 'i-carbon-ibm-cloud-vpc'
|
||||
case 'shadowsocks': return 'i-carbon-send-alt'
|
||||
case 'trojan': return 'i-carbon-security'
|
||||
default: return 'i-carbon-network-1'
|
||||
}
|
||||
}
|
||||
|
||||
function getLatencyColor(ms: number) {
|
||||
if (ms <= 0) return 'bg-gray-500/50'
|
||||
if (ms < 100) return 'bg-success shadow-[0_0_8px_rgba(var(--success-rgb),0.3)]'
|
||||
if (ms < 200) return 'bg-warning shadow-[0_0_8px_rgba(var(--warning-rgb),0.3)]'
|
||||
return 'bg-error shadow-[0_0_8px_rgba(var(--error-rgb),0.3)]'
|
||||
}
|
||||
|
||||
function getLatencyTextColor(ms: number) {
|
||||
if (ms <= 0) return 'text-on-surface-muted'
|
||||
if (ms < 100) return 'text-success'
|
||||
if (ms < 200) return 'text-warning'
|
||||
return 'text-error'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="modelValue"
|
||||
@update:show="$emit('update:modelValue', $event)"
|
||||
preset="card"
|
||||
:title="isEdit ? $t('nodes.edit_node') : $t('nodes.add_node')"
|
||||
class="w-[500px]"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<n-form
|
||||
ref="formRef"
|
||||
label-placement="top"
|
||||
:model="form"
|
||||
>
|
||||
<n-form-item :label="$t('nodes.tag')">
|
||||
<n-input
|
||||
v-model:value="form.tag"
|
||||
:placeholder="$t('nodes.tag_placeholder')"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item :label="$t('nodes.type')">
|
||||
<n-select
|
||||
v-model:value="form.type"
|
||||
:options="[
|
||||
{ label: 'VMess', value: 'vmess' },
|
||||
{ label: 'VLESS', value: 'vless' },
|
||||
{ label: 'Shadowsocks', value: 'shadowsocks' },
|
||||
{ label: 'Trojan', value: 'trojan' }
|
||||
]"
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="col-span-2">
|
||||
<n-form-item :label="$t('nodes.server')">
|
||||
<n-input
|
||||
v-model:value="form.server"
|
||||
:placeholder="$t('nodes.server_placeholder')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
<div>
|
||||
<n-form-item :label="$t('nodes.port')">
|
||||
<n-input-number
|
||||
v-model:value="form.server_port"
|
||||
:placeholder="$t('nodes.port_placeholder')"
|
||||
:show-button="false"
|
||||
/>
|
||||
</n-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="form.type && ['vmess', 'vless', 'trojan'].includes(form.type)">
|
||||
<n-form-item :label="$t('nodes.uuid')">
|
||||
<n-input
|
||||
v-model:value="form.uuid"
|
||||
:placeholder="$t('nodes.uuid_placeholder')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-else-if="form.type === 'shadowsocks'">
|
||||
<n-form-item :label="$t('nodes.password')">
|
||||
<n-input
|
||||
v-model:value="form.password"
|
||||
:placeholder="$t('nodes.password_placeholder')"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item :label="$t('nodes.method')">
|
||||
<n-input
|
||||
v-model:value="form.method"
|
||||
:placeholder="$t('nodes.method_placeholder')"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.type && ['vmess', 'vless'].includes(form.type)">
|
||||
<n-form-item :label="$t('nodes.network')">
|
||||
<n-select
|
||||
v-model:value="form.network"
|
||||
:options="[
|
||||
{ label: 'TCP', value: 'tcp' },
|
||||
{ label: 'WebSocket', value: 'ws' },
|
||||
{ label: 'gRPC', value: 'grpc' }
|
||||
]"
|
||||
/>
|
||||
</n-form-item>
|
||||
</template>
|
||||
</n-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="close">{{ $t('common.cancel') }}</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="save"
|
||||
:disabled="!canSave"
|
||||
>
|
||||
{{ $t('common.save') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import type { NodeConfig } from '@/utils/protocol'
|
||||
import { normalizeNodeType } from '@/utils/protocol'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
editingNode?: NodeConfig | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'save', node: Omit<NodeConfig, 'id'> | NodeConfig): void
|
||||
}>()
|
||||
|
||||
const message = useMessage()
|
||||
const isEdit = computed(() => !!props.editingNode)
|
||||
|
||||
const form = ref<Partial<NodeConfig>>({
|
||||
tag: '',
|
||||
type: 'vmess',
|
||||
server: '',
|
||||
server_port: 443,
|
||||
uuid: '',
|
||||
password: '',
|
||||
method: 'aes-256-gcm',
|
||||
network: 'tcp'
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
const tag = String(form.value.tag ?? '').trim()
|
||||
const server = String(form.value.server ?? '').trim()
|
||||
const port = form.value.server_port
|
||||
const type = form.value.type
|
||||
|
||||
if (!tag) return false
|
||||
if (!server) return false
|
||||
if (typeof port !== 'number' || !Number.isFinite(port) || port <= 0) return false
|
||||
if (!type) return false
|
||||
|
||||
if (normalizeNodeType(type) === 'shadowsocks') {
|
||||
const password = String(form.value.password ?? '').trim()
|
||||
const method = String(form.value.method ?? '').trim()
|
||||
return !!password && !!method
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (val) {
|
||||
if (props.editingNode) {
|
||||
form.value = {
|
||||
...props.editingNode,
|
||||
type: normalizeNodeType(props.editingNode.type) || 'vmess'
|
||||
}
|
||||
} else {
|
||||
form.value = {
|
||||
tag: '',
|
||||
type: 'vmess',
|
||||
server: '',
|
||||
server_port: 443,
|
||||
uuid: '',
|
||||
password: '',
|
||||
method: 'aes-256-gcm',
|
||||
network: 'tcp'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!canSave.value) {
|
||||
message.error('请把必填项填写完整后再保存')
|
||||
return
|
||||
}
|
||||
|
||||
const tag = String(form.value.tag ?? '').trim()
|
||||
const server = String(form.value.server ?? '').trim()
|
||||
const serverPort = form.value.server_port as number
|
||||
|
||||
emit('save', {
|
||||
...form.value,
|
||||
type: normalizeNodeType(form.value.type as string) || 'vmess',
|
||||
tag,
|
||||
server,
|
||||
server_port: serverPort
|
||||
} as any)
|
||||
close()
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="modelValue"
|
||||
@update:show="$emit('update:modelValue', $event)"
|
||||
preset="card"
|
||||
:title="$t('common.import')"
|
||||
class="w-[600px]"
|
||||
:mask-closable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-on-surface-muted">{{ $t('common.paste_link') }}</p>
|
||||
<n-input
|
||||
v-model:value="content"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
class="font-mono"
|
||||
placeholder="vmess://... ss://... https://subscription-url..."
|
||||
/>
|
||||
|
||||
<div class="text-xs text-on-surface-muted">
|
||||
Supported formats: VMess, VLESS, Shadowsocks, Trojan links, or Base64 subscription content.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<n-button @click="close">{{ $t('common.cancel') }}</n-button>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="handleImport"
|
||||
:loading="isImporting"
|
||||
:disabled="!content"
|
||||
>
|
||||
{{ $t('common.import') }}
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useNodeStore } from '@/stores/nodes'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useMessage } from 'naive-ui'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'imported', count: number): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const nodeStore = useNodeStore()
|
||||
const content = ref('')
|
||||
const isImporting = ref(false)
|
||||
|
||||
async function handleImport() {
|
||||
if (!content.value) return
|
||||
|
||||
isImporting.value = true
|
||||
try {
|
||||
const count = await nodeStore.addNodes(content.value)
|
||||
if (count > 0) {
|
||||
emit('imported', count)
|
||||
message.success(t('common.import_success', { count }))
|
||||
close()
|
||||
} else {
|
||||
message.error(t('common.import_fail'))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error('Import failed')
|
||||
} finally {
|
||||
isImporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
content.value = ''
|
||||
emit('update:modelValue', false)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Node List -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0 bg-surface-panel">
|
||||
<div v-if="nodeStore.nodes.length === 0"
|
||||
class="h-full flex flex-col items-center justify-center text-on-surface-muted p-8">
|
||||
<div class="i-material-symbols-dns text-4xl mb-2"></div>
|
||||
<p>{{ $t('common.no_nodes') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-border">
|
||||
<div v-for="node in nodeStore.nodes" :key="node.id || node.tag"
|
||||
class="p-3 hover:bg-surface flex items-center justify-between group transition cursor-default">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-2 h-2 rounded-full" :class="getNodeStatusColor(node)"></div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium text-sm text-on-surface truncate">{{ node.tag || 'Unnamed Node' }}</div>
|
||||
<div class="text-xs text-on-surface-muted flex items-center gap-2">
|
||||
<span class="uppercase bg-surface-overlay px-1 rounded text-[10px]">{{ node.type }}</span>
|
||||
<span class="truncate">{{ node.server }}:{{ node.server_port }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Status -->
|
||||
<div v-if="getNodeStats(node)" class="flex items-center gap-3 text-xs font-mono">
|
||||
<div class="flex items-center gap-1" :class="getLatencyColor(getNodeStats(node)!.latency)">
|
||||
<div class="i-carbon-chart-line"></div>
|
||||
<span>{{ getNodeStats(node)!.latency > 0 ? getNodeStats(node)!.latency + 'ms' : 'Timeout' }}</span>
|
||||
</div>
|
||||
<div v-if="getNodeStats(node)!.loss > 0" class="flex items-center gap-1 text-error">
|
||||
<div class="i-carbon-data-error"></div>
|
||||
<span>{{ getNodeStats(node)!.loss }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<n-button quaternary circle size="small" @click="editNode(node)" :title="$t('common.edit')">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-edit text-xs"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button quaternary circle size="small" @click="testNodeLatency(node)" :loading="isTestingNode(node)"
|
||||
:title="$t('nodes.check_latency')">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-network-check text-xs"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button quaternary circle size="small" @click="shareNode(node)" :title="$t('common.share')">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-share text-xs"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button quaternary circle size="small" type="error" @click="deleteNode(node)"
|
||||
:title="$t('common.delete')">
|
||||
<template #icon>
|
||||
<div class="i-material-symbols-delete text-xs"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NodeEditModal v-model="showEditModal" :editing-node="editingNode" @save="handleSaveNode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useMessage, useDialog } from 'naive-ui'
|
||||
import { useNodeStore } from '@/stores/nodes'
|
||||
import { generateShareLink } from '@/utils/protocol'
|
||||
import type { NodeConfig } from '@/utils/protocol'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import NodeEditModal from './NodeEditModal.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
const nodeStore = useNodeStore()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const { copy } = useClipboard()
|
||||
|
||||
const showEditModal = ref(false)
|
||||
const editingNode = ref<NodeConfig | null>(null)
|
||||
const testingNodes = reactive<Set<string>>(new Set())
|
||||
|
||||
function isTestingNode(node: NodeConfig) {
|
||||
const key = node.id || node.tag
|
||||
return testingNodes.has(key)
|
||||
}
|
||||
|
||||
function getNodeStats(node: NodeConfig) {
|
||||
const key = node.id || node.tag
|
||||
return nodeStore.nodeStats[key]
|
||||
}
|
||||
|
||||
function getNodeStatusColor(node: NodeConfig) {
|
||||
const stats = getNodeStats(node)
|
||||
if (!stats) return 'bg-on-surface-muted/30'
|
||||
|
||||
if (stats.latency < 0) return 'bg-error'
|
||||
if (stats.latency < 100) return 'bg-success'
|
||||
if (stats.latency < 200) return 'bg-warning'
|
||||
return 'bg-error'
|
||||
}
|
||||
|
||||
function getLatencyColor(ms: number) {
|
||||
if (ms < 0) return 'text-error'
|
||||
if (ms < 100) return 'text-success'
|
||||
if (ms < 200) return 'text-warning'
|
||||
return 'text-error'
|
||||
}
|
||||
|
||||
function editNode(node: NodeConfig) {
|
||||
editingNode.value = node
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
async function deleteNode(node: NodeConfig) {
|
||||
dialog.warning({
|
||||
title: t('common.delete') || '确认删除',
|
||||
content: t('nodes.delete_confirm', { name: node.tag }) || `确定要删除节点 ${node.tag} 吗?`,
|
||||
positiveText: t('common.delete') || '删除',
|
||||
negativeText: t('common.cancel') || '取消',
|
||||
onPositiveClick: async () => {
|
||||
if (node.id) {
|
||||
await nodeStore.removeNode(node.id)
|
||||
message.success(t('common.deleted') || '已删除')
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveNode(node: Omit<NodeConfig, 'id'> | NodeConfig) {
|
||||
const ok = await nodeStore.saveNode(node as NodeConfig)
|
||||
if (!ok) {
|
||||
message.error('保存失败,请检查填写内容或查看控制台错误')
|
||||
}
|
||||
}
|
||||
|
||||
async function shareNode(node: NodeConfig) {
|
||||
const link = generateShareLink(node)
|
||||
if (link) {
|
||||
await copy(link)
|
||||
message.success('节点链接已复制')
|
||||
} else {
|
||||
message.error('生成分享链接失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function testNodeLatency(node: NodeConfig) {
|
||||
const key = node.id || node.tag
|
||||
if (testingNodes.has(key)) return
|
||||
|
||||
testingNodes.add(key)
|
||||
try {
|
||||
await nodeStore.checkNode(node)
|
||||
} finally {
|
||||
testingNodes.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods if needed by parent, but mainly NodeManager handles its own list interactions
|
||||
defineExpose({
|
||||
editNode,
|
||||
deleteNode
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,305 @@
|
||||
<template>
|
||||
<!-- 根容器:使用 surface-panel 背景,确保主题适应性 -->
|
||||
<div class="flex flex-col h-full overflow-hidden gap-3 bg-surface-panel/50 rounded-2xl">
|
||||
<!-- 工具栏:固定高度,不缩放 -->
|
||||
<div class="flex-none flex flex-col gap-2 bg-surface/50 rounded-xl border border-border/50 backdrop-blur-md transition-all sticky top-0 z-10"
|
||||
:class="compact ? 'p-2' : 'p-3 md:flex-row md:items-center md:justify-between shadow-sm'">
|
||||
|
||||
<div class="flex items-center gap-2 w-full" :class="compact ? '' : 'md:w-auto'">
|
||||
<!-- 搜索框 -->
|
||||
<div class="relative group flex-1" :class="compact ? '' : 'md:min-w-[200px]'">
|
||||
<div class="absolute inset-y-0 left-0 pl-2.5 flex items-center pointer-events-none">
|
||||
<div
|
||||
class="i-material-symbols-search text-on-surface-muted group-focus-within:text-primary transition-colors text-base">
|
||||
</div>
|
||||
</div>
|
||||
<input v-model="searchQuery" type="text" :placeholder="$t('common.search')"
|
||||
class="pl-9 pr-3 py-1.5 bg-surface-overlay/50 rounded-lg border border-border focus:border-primary/50 focus:bg-surface-overlay outline-none text-on-surface text-xs w-full transition-all placeholder:text-on-surface-muted/50" />
|
||||
</div>
|
||||
|
||||
<!-- 排序 (显示当前模式) -->
|
||||
<button @click="cycleSort"
|
||||
class="px-2 py-1.5 rounded-lg border border-transparent hover:border-border hover:bg-surface-overlay text-on-surface-muted hover:text-primary transition-colors flex items-center gap-1.5"
|
||||
:title="$t('common.sort_by')">
|
||||
<div class="i-material-symbols-sort text-lg"></div>
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider">{{ $t(`common.${sortBy === 'latency' ?
|
||||
'latency_short' : sortBy}`) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 过滤器 (非紧凑模式) -->
|
||||
<div v-if="!compact" class="flex flex-col md:flex-row items-center gap-4 w-full md:w-auto mt-2 md:mt-0">
|
||||
<div class="flex gap-2 overflow-x-auto pb-1 md:pb-0 no-scrollbar w-full md:w-auto">
|
||||
<button v-for="type in uniqueTypes" :key="type" @click="toggleTypeFilter(type)"
|
||||
class="px-3 py-1 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all whitespace-nowrap border"
|
||||
:class="selectedTypes.includes(type)
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'bg-surface-overlay/50 text-on-surface-muted border-border hover:border-primary/30 hover:text-on-surface'">
|
||||
{{ type }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 节点列表区域 -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-1">
|
||||
<div class="grid gap-2 pb-2" :class="compact ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3'">
|
||||
<div v-for="node in sortedNodes" :key="node.id" @click="selectNode(node)"
|
||||
class="group relative rounded-xl border transition-all duration-200 cursor-pointer overflow-hidden backdrop-blur-sm select-none"
|
||||
:class="[
|
||||
modelValue === (node.id || node.tag)
|
||||
? 'bg-primary/5 border-primary shadow-[0_0_15px_-5px_rgba(var(--rgb-primary),0.3)]'
|
||||
: 'bg-surface/50 border-border hover:border-primary/50 hover:bg-surface-overlay'
|
||||
]">
|
||||
|
||||
<!-- 选中时的背景高亮指示 -->
|
||||
<div v-if="modelValue === (node.id || node.tag)"
|
||||
class="absolute inset-0 bg-gradient-to-r from-primary/10 to-transparent opacity-50 pointer-events-none">
|
||||
</div>
|
||||
|
||||
<div class="relative z-10 flex items-center gap-3" :class="compact ? 'p-2.5 pl-3' : 'p-3'">
|
||||
|
||||
<!-- Protocol Icon -->
|
||||
<div class="shrink-0 w-8 h-8 rounded-lg flex items-center justify-center transition-colors border"
|
||||
:class="modelValue === (node.id || node.tag)
|
||||
? 'bg-primary/20 text-primary border-primary/20'
|
||||
: 'bg-surface-overlay border-border text-on-surface-muted group-hover:text-primary group-hover:border-primary/30'">
|
||||
<div :class="getNodeIcon(node.type)" class="text-lg"></div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span
|
||||
class="text-xs font-bold text-on-surface truncate group-hover:text-primary transition-colors">
|
||||
{{ node.tag || node.server }}
|
||||
</span>
|
||||
<!-- Latency Badge -->
|
||||
<span class="shrink-0 font-mono text-[10px] font-bold"
|
||||
:class="getLatencyTextColor(node.latency)">
|
||||
{{ node.latency > 0 ? node.latency + 'ms' : '--' }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- Sub-info -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-[9px] uppercase tracking-wider text-on-surface-muted/70 border border-border px-1 rounded bg-surface-overlay/50 group-hover:border-primary/20 transition-colors">
|
||||
{{ node.type }}
|
||||
</span>
|
||||
<span v-if="!compact" class="text-[10px] text-on-surface-muted/50 truncate">{{
|
||||
node.server }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态展示 -->
|
||||
<div v-if="sortedNodes.length === 0"
|
||||
class="col-span-full flex flex-col items-center justify-center py-10 text-center opacity-70">
|
||||
<div class="w-16 h-16 rounded-full bg-surface-overlay flex items-center justify-center mb-3">
|
||||
<div class="i-material-symbols-dns-outline text-3xl text-on-surface-muted/50"></div>
|
||||
</div>
|
||||
<span class="text-xs font-medium text-on-surface-muted">No nodes found</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { NodeConfig } from '@/utils/protocol'
|
||||
import { useNodeStore } from '@/stores/nodes'
|
||||
|
||||
interface ExtendedNode extends NodeConfig {
|
||||
latency: number
|
||||
loss: number
|
||||
load: number
|
||||
bandwidth: number // bytes/sec
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string | null | undefined
|
||||
nodes: NodeConfig[]
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
}>()
|
||||
|
||||
// 响应式状态
|
||||
const searchQuery = ref('')
|
||||
const selectedTypes = ref<string[]>([])
|
||||
const sortBy = ref<'latency' | 'load' | 'bandwidth'>('latency')
|
||||
|
||||
const nodeStore = useNodeStore()
|
||||
|
||||
// 合并节点实时统计数据
|
||||
const nodesWithStats = computed<ExtendedNode[]>(() => {
|
||||
return props.nodes.map(node => {
|
||||
const defaultStat = { latency: -1, loss: 0 }
|
||||
const key = node.id || node.tag
|
||||
const stat = (key && nodeStore.nodeStats[key]) || defaultStat
|
||||
|
||||
return {
|
||||
...node,
|
||||
latency: stat.latency,
|
||||
loss: stat.loss,
|
||||
load: 0, // 待实现
|
||||
bandwidth: 0 // 待实现
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 提取不重复的节点类型
|
||||
const uniqueTypes = computed(() => {
|
||||
const types = new Set(props.nodes.map(n => n.type))
|
||||
return Array.from(types).sort()
|
||||
})
|
||||
|
||||
// 过滤与排序逻辑
|
||||
const sortedNodes = computed(() => {
|
||||
let result = [...nodesWithStats.value]
|
||||
|
||||
// 搜索过滤
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
result = result.filter(n =>
|
||||
(n.tag && n.tag.toLowerCase().includes(q)) ||
|
||||
(n.server && n.server.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
// 类型过滤
|
||||
if (selectedTypes.value.length > 0) {
|
||||
result = result.filter(n => selectedTypes.value.includes(n.type))
|
||||
}
|
||||
|
||||
// 排序
|
||||
result.sort((a, b) => {
|
||||
if (sortBy.value === 'latency') {
|
||||
const latA = a.latency <= 0 ? 999999 : a.latency
|
||||
const latB = b.latency <= 0 ? 999999 : b.latency
|
||||
return latA - latB
|
||||
}
|
||||
if (sortBy.value === 'load') return a.load - b.load
|
||||
if (sortBy.value === 'bandwidth') return b.bandwidth - a.bandwidth
|
||||
return 0
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
// 切换类型过滤器
|
||||
function toggleTypeFilter(type: string) {
|
||||
const index = selectedTypes.value.indexOf(type)
|
||||
if (index > -1) {
|
||||
selectedTypes.value.splice(index, 1)
|
||||
} else {
|
||||
selectedTypes.value.push(type)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换排序模式
|
||||
function cycleSort() {
|
||||
const modes: ('latency' | 'load' | 'bandwidth')[] = ['latency', 'load', 'bandwidth']
|
||||
const currentIndex = modes.indexOf(sortBy.value)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
sortBy.value = modes[nextIndex]!
|
||||
}
|
||||
|
||||
// 选择节点触发事件
|
||||
function selectNode(node: ExtendedNode) {
|
||||
const id = node.id || node.tag
|
||||
if (id) {
|
||||
emit('update:modelValue', id)
|
||||
}
|
||||
}
|
||||
|
||||
// 根据节点协议获取图标
|
||||
function getNodeIcon(type: string) {
|
||||
const t = type.toLowerCase()
|
||||
if (t.includes('vmess')) return 'i-carbon-network-4'
|
||||
if (t.includes('vless')) return 'i-carbon-ibm-cloud-vpc'
|
||||
if (t.includes('shadowsocks') || t.includes('ss')) return 'i-carbon-send-alt'
|
||||
if (t.includes('trojan')) return 'i-carbon-security'
|
||||
if (t.includes('hysteria')) return 'i-carbon-flash'
|
||||
if (t.includes('tuic')) return 'i-carbon-rocket'
|
||||
return 'i-carbon-network-1'
|
||||
}
|
||||
|
||||
// 延迟状态文字颜色
|
||||
function getLatencyTextColor(ms: number) {
|
||||
if (ms <= 0) return 'text-on-surface-muted/40'
|
||||
if (ms < 100) return 'text-success'
|
||||
if (ms < 200) return 'text-warning'
|
||||
return 'text-error'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--primary-rgb), 0.15);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(var(--primary-rgb), 0.35);
|
||||
}
|
||||
|
||||
/* 缩放动画 */
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
transform: scale(0.6);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
/* 脉冲动画 */
|
||||
@keyframes pulse-slow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.2;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条 */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { useTheme } from './useTheme'
|
||||
|
||||
describe('useTheme', () => {
|
||||
const localStorageMock = (() => {
|
||||
let store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: vi.fn((key: string) => store[key] || null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
store[key] = value.toString()
|
||||
}),
|
||||
clear: () => {
|
||||
store = {}
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
})
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(), // Deprecated
|
||||
removeListener: vi.fn(), // Deprecated
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear()
|
||||
document.documentElement.className = ''
|
||||
// Reset internal state if possible, but useTheme uses module-level state.
|
||||
// So we might need to reset via public methods
|
||||
const { setTheme } = useTheme()
|
||||
setTheme('auto')
|
||||
})
|
||||
|
||||
it('should initialize with default theme', () => {
|
||||
const { theme } = useTheme()
|
||||
expect(theme.value).toBe('auto')
|
||||
})
|
||||
|
||||
it('should set theme and persist to localStorage', () => {
|
||||
const { setTheme, theme } = useTheme()
|
||||
setTheme('dark')
|
||||
expect(theme.value).toBe('dark')
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('theme', 'dark')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
})
|
||||
|
||||
it('should toggle theme', () => {
|
||||
const { toggleTheme, resolvedTheme } = useTheme()
|
||||
// Assume start at auto -> system light (mock default) -> resolved light
|
||||
// Actually our mock implementation of matchMedia returns false for dark preference by default
|
||||
|
||||
// Force set to dark first
|
||||
const { setTheme } = useTheme()
|
||||
setTheme('dark')
|
||||
expect(resolvedTheme.value).toBe('dark')
|
||||
|
||||
toggleTheme()
|
||||
expect(resolvedTheme.value).toBe('light')
|
||||
|
||||
toggleTheme()
|
||||
expect(resolvedTheme.value).toBe('dark')
|
||||
})
|
||||
|
||||
it('should respond to system preference when auto', () => {
|
||||
const { setTheme } = useTheme()
|
||||
setTheme('auto')
|
||||
|
||||
// Simulate system dark mode
|
||||
// Note: Since useTheme creates mediaQuery inside, and we mocked matchMedia globally,
|
||||
// we can't easily trigger the event listener attached inside the closure without more complex mocking.
|
||||
// But we can check if it reads the initial state correctly.
|
||||
|
||||
// Let's mock matchMedia to return true for dark
|
||||
window.matchMedia = vi.fn().mockImplementation(query => ({
|
||||
matches: true,
|
||||
media: query,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
}))
|
||||
|
||||
// Re-run apply logic (simulate mount)
|
||||
// const { onMounted } = useTheme()
|
||||
// Since we can't easily access lifecycle hooks outside component,
|
||||
// we might just test the logic if we export applyTheme or similar.
|
||||
// Or we rely on the fact that setTheme calls applyTheme.
|
||||
setTheme('auto')
|
||||
|
||||
// With matches: true
|
||||
// expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
// However, the `mediaQuery` const in `useTheme.ts` is initialized ONCE when the module is imported.
|
||||
// So changing window.matchMedia AFTER import won't affect the `mediaQuery` variable inside `useTheme`.
|
||||
// This makes testing `auto` mode tricky with module-level state.
|
||||
// For unit testing purposes, it's better if `useTheme` was a store or we could reset it.
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'auto'
|
||||
export type ThemeColor = 'green' | 'blue' | 'purple' | 'orange' | 'red'
|
||||
|
||||
const theme = ref<Theme>('auto')
|
||||
const resolvedTheme = ref<'light' | 'dark'>('dark') // Default to dark
|
||||
const themeColor = ref<ThemeColor>('green')
|
||||
|
||||
export function useTheme() {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
function applyTheme() {
|
||||
const root = document.documentElement
|
||||
const isDark = theme.value === 'dark' || (theme.value === 'auto' && mediaQuery.matches)
|
||||
|
||||
resolvedTheme.value = isDark ? 'dark' : 'light'
|
||||
|
||||
// Apply dark mode
|
||||
if (isDark) {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
|
||||
// Apply theme color
|
||||
// Remove previous theme classes
|
||||
root.classList.remove('theme-green', 'theme-blue', 'theme-purple', 'theme-orange', 'theme-red')
|
||||
root.classList.add(`theme-${themeColor.value}`)
|
||||
}
|
||||
|
||||
function setTheme(newTheme: Theme) {
|
||||
theme.value = newTheme
|
||||
localStorage.setItem('theme', newTheme)
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
function setThemeColor(color: ThemeColor) {
|
||||
themeColor.value = color
|
||||
localStorage.setItem('themeColor', color)
|
||||
applyTheme()
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const next = resolvedTheme.value === 'dark' ? 'light' : 'dark'
|
||||
setTheme(next)
|
||||
}
|
||||
|
||||
function handleSystemChange() {
|
||||
if (theme.value === 'auto') {
|
||||
applyTheme()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme
|
||||
if (savedTheme && ['light', 'dark', 'auto'].includes(savedTheme)) {
|
||||
theme.value = savedTheme
|
||||
}
|
||||
|
||||
const savedColor = localStorage.getItem('themeColor') as ThemeColor
|
||||
if (savedColor && ['green', 'blue', 'purple', 'orange', 'red'].includes(savedColor)) {
|
||||
themeColor.value = savedColor
|
||||
}
|
||||
|
||||
applyTheme()
|
||||
mediaQuery.addEventListener('change', handleSystemChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mediaQuery.removeEventListener('change', handleSystemChange)
|
||||
})
|
||||
|
||||
const isDark = computed(() => resolvedTheme.value === 'dark')
|
||||
|
||||
return {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
isDark,
|
||||
themeColor,
|
||||
setTheme,
|
||||
setThemeColor,
|
||||
toggleTheme
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zhCN from './locales/zh-CN.json'
|
||||
import enUS from './locales/en-US.json'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'zh-CN', // default locale
|
||||
fallbackLocale: 'en-US',
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS
|
||||
}
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="h-screen w-screen flex flex-col bg-background text-on-surface overflow-hidden">
|
||||
<!-- Title Bar (Drag Region) -->
|
||||
<div
|
||||
class="h-12 flex-shrink-0 flex items-center justify-between px-4 z-50 select-none app-drag-region bg-surface/80 backdrop-blur-md border-b border-border">
|
||||
<div class="text-xs font-bold text-on-surface-variant flex items-center gap-2">
|
||||
<img src="/logo.svg" alt="LagZero" class="h-6">
|
||||
<span class="text-lg font-bold text-on-surface">LagZero</span>
|
||||
</div>
|
||||
<div class="flex gap-1 no-drag items-center">
|
||||
<ThemeToggle />
|
||||
<div class="w-[1px] h-4 bg-border mx-2"></div>
|
||||
<button @click="minimize"
|
||||
class="w-7 h-7 flex items-center justify-center rounded hover:bg-on-surface/10 text-on-surface-variant hover:text-on-surface transition">
|
||||
<div class="i-material-symbols-remove text-lg"></div>
|
||||
</button>
|
||||
<button @click="maximize"
|
||||
class="w-7 h-7 flex items-center justify-center rounded hover:bg-on-surface/10 text-on-surface-variant hover:text-on-surface transition">
|
||||
<div class="i-material-symbols-crop-square text-lg"></div>
|
||||
</button>
|
||||
<button @click="close"
|
||||
class="w-7 h-7 flex items-center justify-center rounded hover:bg-error hover:text-white text-on-surface-variant transition">
|
||||
<div class="i-material-symbols-close text-lg"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Area -->
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
class="bg-surface-panel flex flex-col pt-4 z-40 border-r border-border transition-all duration-300 ease-in-out"
|
||||
:class="isCollapsed ? 'w-16 items-center' : 'w-64'" @wheel="handleSidebarWheel">
|
||||
<div class="flex-1 flex flex-col gap-2 px-2 w-full">
|
||||
<!-- Navigation Items -->
|
||||
<router-link to="/dashboard" active-class="bg-primary/10 text-primary"
|
||||
class="flex items-center gap-3 p-2 rounded-lg text-on-surface-muted hover:bg-surface hover:text-on-surface transition-colors h-10"
|
||||
:class="[
|
||||
isCollapsed ? 'justify-center w-10 mx-auto' : 'w-full',
|
||||
$route.path === '/dashboard' ? 'text-primary' : ''
|
||||
]" :title="isCollapsed ? $t('common.dashboard') : ''">
|
||||
<div class="i-material-symbols-dashboard text-xl flex-shrink-0"></div>
|
||||
<span v-if="!isCollapsed"
|
||||
class="text-sm font-medium whitespace-nowrap overflow-hidden transition-opacity duration-300">{{
|
||||
$t('common.dashboard') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/games" active-class="bg-primary/10 text-primary"
|
||||
class="flex items-center gap-3 p-2 rounded-lg text-on-surface-muted hover:bg-surface hover:text-on-surface transition-colors h-10"
|
||||
:class="[
|
||||
isCollapsed ? 'justify-center w-10 mx-auto' : 'w-full',
|
||||
$route.path === '/games' ? 'text-primary' : ''
|
||||
]" :title="isCollapsed ? $t('games.library') : ''">
|
||||
<div class="i-material-symbols-sports-esports text-xl flex-shrink-0"></div>
|
||||
<span v-if="!isCollapsed"
|
||||
class="text-sm font-medium whitespace-nowrap overflow-hidden transition-opacity duration-300">{{
|
||||
$t('games.library') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/nodes" active-class="bg-primary/10 text-primary"
|
||||
class="flex items-center gap-3 p-2 rounded-lg text-on-surface-muted hover:bg-surface hover:text-on-surface transition-colors h-10"
|
||||
:class="[
|
||||
isCollapsed ? 'justify-center w-10 mx-auto' : 'w-full',
|
||||
$route.path === '/nodes' ? 'text-primary' : ''
|
||||
]" :title="isCollapsed ? $t('common.nodes') : ''">
|
||||
<div class="i-material-symbols-dns text-xl flex-shrink-0"></div>
|
||||
<span v-if="!isCollapsed"
|
||||
class="text-sm font-medium whitespace-nowrap overflow-hidden transition-opacity duration-300">{{
|
||||
$t('common.nodes') }}</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/settings" active-class="bg-primary/10 text-primary"
|
||||
class="flex items-center gap-3 p-2 rounded-lg text-on-surface-muted hover:bg-surface hover:text-on-surface transition-colors h-10"
|
||||
:class="[
|
||||
isCollapsed ? 'justify-center w-10 mx-auto' : 'w-full',
|
||||
$route.path === '/settings' ? 'text-primary' : ''
|
||||
]" :title="isCollapsed ? $t('common.settings') : ''">
|
||||
<div class="i-material-symbols-settings text-xl flex-shrink-0"></div>
|
||||
<span v-if="!isCollapsed"
|
||||
class="text-sm font-medium whitespace-nowrap overflow-hidden transition-opacity duration-300">{{
|
||||
$t('common.settings') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Toggle Button -->
|
||||
<div class="p-2 w-full border-t border-border mt-auto">
|
||||
<button @click="toggleSidebar"
|
||||
class="flex items-center gap-3 p-2 rounded-lg text-on-surface-muted hover:bg-surface hover:text-on-surface transition-colors w-full h-10"
|
||||
:class="isCollapsed ? 'justify-center w-10 mx-auto' : ''" :title="$t('common.collapse')">
|
||||
<div class="i-material-symbols-menu-open text-xl transform transition-transform duration-300"
|
||||
:class="isCollapsed ? 'rotate-180' : ''"></div>
|
||||
<span v-if="!isCollapsed" class="text-sm font-medium whitespace-nowrap overflow-hidden">{{
|
||||
$t('common.collapse') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 flex flex-col h-full relative overflow-hidden bg-background">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="slide-fade" mode="out-in">
|
||||
<component :is="Component" class="h-full overflow-hidden" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watchEffect } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isCollapsed = ref(true)
|
||||
const $route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 屏幕宽度小于 1024px 时自动收起侧边栏
|
||||
watchEffect(() => {
|
||||
if (width.value < 1024) {
|
||||
isCollapsed.value = true
|
||||
}
|
||||
})
|
||||
|
||||
function toggleSidebar() {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
}
|
||||
|
||||
function handleSidebarWheel(e: WheelEvent) {
|
||||
// Only handle if current route is dashboard, games or nodes
|
||||
if (!['/dashboard', '/games', '/nodes'].includes($route.path)) return
|
||||
|
||||
if (e.deltaY > 0) {
|
||||
// Scroll Down -> Go to next page
|
||||
if ($route.path === '/dashboard') {
|
||||
router.push('/games')
|
||||
} else if ($route.path === '/games') {
|
||||
router.push('/nodes')
|
||||
}
|
||||
} else {
|
||||
// Scroll Up -> Go to prev page
|
||||
if ($route.path === '/games') {
|
||||
router.push('/dashboard')
|
||||
} else if ($route.path === '/nodes') {
|
||||
router.push('/games')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const minimize = () => window.electron.minimize()
|
||||
// @ts-ignore
|
||||
const maximize = () => window.electron.maximize()
|
||||
// @ts-ignore
|
||||
const close = () => window.electron.close()
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.app-drag-region {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
.no-drag {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.2s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"common": {
|
||||
"status": "Status",
|
||||
"connected": "Connected",
|
||||
"disconnected": "Disconnected",
|
||||
"running": "Running",
|
||||
"stopped": "Stopped",
|
||||
"start": "Start",
|
||||
"stop": "Stop",
|
||||
"start_acceleration": "Start Acceleration",
|
||||
"stop_acceleration": "Stop Acceleration",
|
||||
"import": "Import",
|
||||
"export": "Export",
|
||||
"share": "Share",
|
||||
"nodes": "Nodes",
|
||||
"collapse": "Collapse",
|
||||
"dashboard": "Accelerate",
|
||||
"rules": "Rules",
|
||||
"settings": "Settings",
|
||||
"traffic": "Traffic",
|
||||
"latency": "Real-time Latency",
|
||||
"latency_short": "Latency",
|
||||
"load": "Load",
|
||||
"bandwidth": "Bandwidth",
|
||||
"speed": "Speed",
|
||||
"sort_by": "Sort by:",
|
||||
"network_stability": "Network Stability",
|
||||
"live": "Live",
|
||||
"uncategorized": "Uncategorized",
|
||||
"search": "Search",
|
||||
"paste_link": "Paste links here (Batch/Sub)",
|
||||
"no_nodes": "No nodes available. Import some!",
|
||||
"import_success": "Successfully imported {count} nodes",
|
||||
"import_fail": "No valid nodes found or duplicates ignored",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"deleted": "Deleted",
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"categories": "Categories",
|
||||
"optional": "Optional",
|
||||
"more": "More"
|
||||
},
|
||||
"nodes": {
|
||||
"add_node": "Add Node",
|
||||
"edit_node": "Edit Node",
|
||||
"tag": "Name / Tag",
|
||||
"tag_placeholder": "Node Name",
|
||||
"type": "Type",
|
||||
"server": "Server Address",
|
||||
"server_placeholder": "e.g. example.com",
|
||||
"port": "Port",
|
||||
"port_placeholder": "443",
|
||||
"uuid": "UUID / Password",
|
||||
"uuid_placeholder": "UUID or Password",
|
||||
"password": "Password",
|
||||
"password_placeholder": "Password",
|
||||
"method": "Encryption Method",
|
||||
"method_placeholder": "e.g. aes-256-gcm",
|
||||
"network": "Network",
|
||||
"select_node": "Select Node",
|
||||
"select_node_desc": "Choose the best connection for your region",
|
||||
"manage_nodes": "Manage Nodes",
|
||||
"no_nodes_found": "No nodes found matching your criteria.",
|
||||
"check_latency": "Check Latency",
|
||||
"delete_confirm": "Are you sure you want to delete node {name}?",
|
||||
"save_failed": "Save failed, please check inputs or console",
|
||||
"no_nodes_to_export": "No nodes to export",
|
||||
"export_success": "{count} node configurations copied to clipboard",
|
||||
"export_failed": "Export failed"
|
||||
},
|
||||
"games": {
|
||||
"library": "Game Library",
|
||||
"no_game_selected": "No game selected. Please select a game to accelerate.",
|
||||
"go_library": "Go to Library",
|
||||
"packet_loss": "Packet Loss",
|
||||
"duration": "Duration",
|
||||
"configuration": "Configuration",
|
||||
"mode": "Mode",
|
||||
"mode_process": "Process Mode (Recommended)",
|
||||
"mode_process_desc": "Only accelerate game processes.",
|
||||
"mode_process_subdesc": "Accelerate specific game processes only",
|
||||
"mode_routing": "Routing Mode",
|
||||
"mode_routing_desc": "Global routing based on rules.",
|
||||
"mode_routing_subdesc": "System-wide routing with rules",
|
||||
"search_placeholder": "Search games...",
|
||||
"select_game_desc": "Select a game from your library to start accelerating and optimize your network performance.",
|
||||
"scan_local": "Scan Local",
|
||||
"scanning": "Scanning...",
|
||||
"no_games_found": "No games found",
|
||||
"selected": "Selected",
|
||||
"running": "Running",
|
||||
"add_game": "Add Game",
|
||||
"name": "Name",
|
||||
"process_name": "Process Name",
|
||||
"process_name_tip": "Separate multiple process names with commas",
|
||||
"icon": "Icon Class",
|
||||
"category": "Category",
|
||||
"delete_confirm": "Are you sure to delete this game?",
|
||||
"advanced_config": "Advanced Configuration",
|
||||
"chain_proxy": "Enable Chain Proxy Detection",
|
||||
"chain_proxy_desc": "Automatically detect and proxy child processes started by the game.",
|
||||
"add_process": "Add Process"
|
||||
},
|
||||
"rules": {
|
||||
"routing": "Routing",
|
||||
"ip_rules": "IP Rules",
|
||||
"drag_tip": "Drag to reorder rules",
|
||||
"add_rule": "Add Rule",
|
||||
"geoip_placeholder": "geoip:private\ngeosite:cn",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"value": "Value",
|
||||
"value_placeholder": "e.g. google.com or cn",
|
||||
"outbound": "Outbound",
|
||||
"edit_rule": "Edit Rule",
|
||||
"delete_confirm": "Are you sure to delete this rule?"
|
||||
},
|
||||
"settings": {
|
||||
"language": "Language",
|
||||
"theme_color": "Theme Color",
|
||||
"coming_soon": "Settings coming soon...",
|
||||
"toggle_theme": "Toggle Theme",
|
||||
"network": "Network",
|
||||
"check_method": "Latency Check Method",
|
||||
"check_interval": "Auto Check Interval"
|
||||
},
|
||||
"categories": {
|
||||
"new_category_placeholder": "Enter new category name..."
|
||||
},
|
||||
"dashboard": {
|
||||
"hint": "Pro Tip: After starting acceleration, we recommend restarting the game to ensure all traffic is correctly routed through the virtual network interface.",
|
||||
"securing_connection": "Securing Connection",
|
||||
"ready_to_boost": "Ready to Boost",
|
||||
"optimize_desc": "Optimize your network experience and say goodbye to lag.",
|
||||
"active_protection": "Active Real-time Protection Enabled",
|
||||
"did_you_know": "Did you know?",
|
||||
"node_switch_failed": "Node switch failed, please try again",
|
||||
"please_select_node": "Please select a node first",
|
||||
"start_failed": "Failed to start acceleration",
|
||||
"singbox_error": "sing-box error occurred"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"common": {
|
||||
"status": "状态",
|
||||
"connected": "已连接",
|
||||
"disconnected": "未连接",
|
||||
"running": "运行中",
|
||||
"stopped": "已停止",
|
||||
"start": "启动",
|
||||
"stop": "停止",
|
||||
"start_acceleration": "开始加速",
|
||||
"stop_acceleration": "停止加速",
|
||||
"import": "导入",
|
||||
"export": "导出",
|
||||
"share": "分享",
|
||||
"nodes": "节点",
|
||||
"collapse": "折叠",
|
||||
"dashboard": "加速",
|
||||
"rules": "规则",
|
||||
"settings": "设置",
|
||||
"traffic": "流量",
|
||||
"latency": "实时延迟",
|
||||
"latency_short": "延迟",
|
||||
"load": "负载",
|
||||
"bandwidth": "带宽",
|
||||
"speed": "速度",
|
||||
"sort_by": "排序方式:",
|
||||
"network_stability": "网络稳定性",
|
||||
"live": "实时",
|
||||
"uncategorized": "未分类",
|
||||
"search": "搜索",
|
||||
"paste_link": "在此粘贴链接 (支持批量/订阅)",
|
||||
"no_nodes": "暂无节点,请导入!",
|
||||
"import_success": "成功导入 {count} 个节点",
|
||||
"import_fail": "未找到有效节点或全部重复",
|
||||
"save": "保存",
|
||||
"cancel": "取消",
|
||||
"delete": "删除",
|
||||
"deleted": "已删除",
|
||||
"edit": "编辑",
|
||||
"add": "添加",
|
||||
"categories": "分类管理",
|
||||
"optional": "可选",
|
||||
"more": "更多"
|
||||
},
|
||||
"nodes": {
|
||||
"add_node": "添加节点",
|
||||
"edit_node": "编辑节点",
|
||||
"tag": "名称 / 标签",
|
||||
"tag_placeholder": "节点名称",
|
||||
"type": "类型",
|
||||
"server": "服务器地址",
|
||||
"server_placeholder": "例如: example.com",
|
||||
"port": "端口",
|
||||
"port_placeholder": "443",
|
||||
"uuid": "UUID / 密码",
|
||||
"uuid_placeholder": "UUID 或 密码",
|
||||
"password": "密码",
|
||||
"password_placeholder": "密码",
|
||||
"method": "加密方式",
|
||||
"method_placeholder": "例如: aes-256-gcm",
|
||||
"network": "传输协议",
|
||||
"select_node": "选择节点",
|
||||
"select_node_desc": "选择适合您所在地区的最佳连接",
|
||||
"manage_nodes": "管理节点",
|
||||
"no_nodes_found": "未找到符合条件的节点。",
|
||||
"check_latency": "测速",
|
||||
"delete_confirm": "确定要删除节点 {name} 吗?",
|
||||
"save_failed": "保存失败,请检查填写内容或查看控制台错误",
|
||||
"no_nodes_to_export": "没有可导出的节点",
|
||||
"export_success": "已复制 {count} 个节点配置到剪贴板",
|
||||
"export_failed": "导出失败"
|
||||
},
|
||||
"games": {
|
||||
"library": "游戏库",
|
||||
"no_game_selected": "未选择游戏,请先选择一个游戏进行加速。",
|
||||
"go_library": "前往游戏库",
|
||||
"packet_loss": "丢包率",
|
||||
"connection": "当前连接",
|
||||
"duration": "加速时长",
|
||||
"configuration": "加速配置",
|
||||
"mode": "加速模式",
|
||||
"mode_process": "进程模式 (推荐)",
|
||||
"mode_process_desc": "仅加速游戏进程,不影响其他应用。",
|
||||
"mode_process_subdesc": "仅加速指定的游戏进程",
|
||||
"mode_routing": "路由模式",
|
||||
"mode_routing_desc": "基于路由规则进行全局分流。",
|
||||
"mode_routing_subdesc": "基于规则的系统级路由",
|
||||
"search_placeholder": "搜索游戏...",
|
||||
"select_game_desc": "从游戏库中选择一个游戏以开始加速并优化您的网络性能。",
|
||||
"scan_local": "扫描游戏",
|
||||
"scanning": "扫描中...",
|
||||
"no_games_found": "未找到游戏",
|
||||
"selected": "已选择",
|
||||
"running": "运行中",
|
||||
"add_game": "添加游戏",
|
||||
"name": "游戏名称",
|
||||
"process_name": "进程名称",
|
||||
"process_name_tip": "多个进程名请用逗号分隔",
|
||||
"icon": "图标类名",
|
||||
"category": "分类",
|
||||
"delete_confirm": "确定要删除这个游戏吗?",
|
||||
"advanced_config": "高级配置",
|
||||
"chain_proxy": "启用链式代理检测",
|
||||
"chain_proxy_desc": "自动检测并代理游戏启动的子进程。",
|
||||
"add_process": "添加进程"
|
||||
},
|
||||
"rules": {
|
||||
"routing": "路由模式",
|
||||
"ip_rules": "IP 规则",
|
||||
"drag_tip": "拖拽以重新排序",
|
||||
"add_rule": "添加规则",
|
||||
"geoip_placeholder": "geoip:private\ngeosite:cn",
|
||||
"name": "规则名称",
|
||||
"type": "类型",
|
||||
"value": "匹配值",
|
||||
"value_placeholder": "例如: google.com 或 cn",
|
||||
"outbound": "出站动作",
|
||||
"edit_rule": "编辑规则",
|
||||
"delete_confirm": "确定要删除此规则吗?"
|
||||
},
|
||||
"settings": {
|
||||
"language": "语言",
|
||||
"theme_color": "主题颜色",
|
||||
"coming_soon": "设置功能开发中...",
|
||||
"toggle_theme": "切换主题",
|
||||
"network": "网络设置",
|
||||
"check_method": "延迟检测方式",
|
||||
"check_interval": "自动检测间隔"
|
||||
},
|
||||
"categories": {
|
||||
"new_category_placeholder": "输入新分类名称..."
|
||||
},
|
||||
"dashboard": {
|
||||
"hint": "温馨提示:开启加速后,建议重启游戏以确保所有流量正确经过虚拟网卡。",
|
||||
"securing_connection": "正在建立安全加速通道",
|
||||
"ready_to_boost": "准备就绪项目已加载",
|
||||
"optimize_desc": "优化您的网络体验,告别延迟。",
|
||||
"active_protection": "全时段网络加速保护已开启",
|
||||
"running_desc": "LagZero 正在通过 {node} 传输您的流量,并开启低延迟优先级。",
|
||||
"idle_desc": "选择您偏好的节点并点击下方按钮,即可绕过拥堵并减少延迟波动。",
|
||||
"did_you_know": "你知道吗?",
|
||||
"node_switch_failed": "切换节点失败,请重试",
|
||||
"please_select_node": "请先选择一个节点",
|
||||
"start_failed": "启动加速失败",
|
||||
"singbox_error": "sing-box 发生错误"
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { createApp } from 'vue'
|
||||
import 'virtual:uno.css'
|
||||
import '@unocss/reset/tailwind.css'
|
||||
import './style.css'
|
||||
import './styles/themes.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './stores'
|
||||
import i18n from './i18n'
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(pinia)
|
||||
.use(i18n)
|
||||
.mount('#app')
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import MainLayout from '@/layouts/MainLayout.vue'
|
||||
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: MainLayout,
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', component: () => import('@/views/dashboard/index.vue') },
|
||||
{ path: 'games', component: () => import('@/views/library/index.vue') },
|
||||
{ path: 'nodes', component: () => import('@/views/nodes/index.vue') },
|
||||
{ path: 'settings', component: () => import('@/views/settings/index.vue') },
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Category } from '@/types'
|
||||
|
||||
export const useCategoryStore = defineStore('categories', () => {
|
||||
const categories = ref<Category[]>([])
|
||||
|
||||
async function loadCategories() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
categories.value = await window.categories.getAll()
|
||||
} catch (e) {
|
||||
console.error('Failed to load categories:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addCategory(category: Category) {
|
||||
// @ts-ignore
|
||||
categories.value = await window.categories.save(category)
|
||||
}
|
||||
|
||||
async function updateCategory(category: Category) {
|
||||
// @ts-ignore
|
||||
categories.value = await window.categories.save(category)
|
||||
}
|
||||
|
||||
async function removeCategory(id: string) {
|
||||
// @ts-ignore
|
||||
categories.value = await window.categories.delete(id)
|
||||
}
|
||||
|
||||
loadCategories()
|
||||
|
||||
return {
|
||||
categories,
|
||||
loadCategories,
|
||||
addCategory,
|
||||
updateCategory,
|
||||
removeCategory
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, toRaw } from 'vue'
|
||||
import type { Game } from '@/types'
|
||||
import { useNodeStore } from './nodes'
|
||||
import { generateSingboxConfig } from '@/utils/singbox-config'
|
||||
|
||||
export type { Game }
|
||||
|
||||
export function isRoutingOnlyPresetGame(game: Pick<Game, 'name' | 'routingRules'> | null | undefined): boolean {
|
||||
if (!game) return false
|
||||
const name = String(game.name || '').toLowerCase()
|
||||
const rules = Array.isArray(game.routingRules) ? game.routingRules.map(r => String(r).toLowerCase()) : []
|
||||
|
||||
if (rules.includes('bypass_cn')) return true
|
||||
if (name.includes('global') || name.includes('全局加速')) return true
|
||||
if (name.includes('bypass cn') || name.includes('绕过大陆')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeProxyModeForPreset(game: Game): Game {
|
||||
if (!isRoutingOnlyPresetGame(game)) return game
|
||||
if (game.proxyMode === 'routing') return game
|
||||
return { ...game, proxyMode: 'routing' }
|
||||
}
|
||||
|
||||
export const useGameStore = defineStore('games', () => {
|
||||
// 游戏特征库
|
||||
const gameLibrary = ref<Game[]>([])
|
||||
|
||||
// 当前选中的游戏 (Dashboard 展示)
|
||||
const currentGameId = ref<string | null>(null)
|
||||
|
||||
const currentGame = computed(() =>
|
||||
gameLibrary.value.find(g => g.id === currentGameId.value) || null
|
||||
)
|
||||
|
||||
// 扫描到的运行中游戏
|
||||
const runningGames = ref<string[]>([])
|
||||
|
||||
// 初始化加载
|
||||
async function init() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const games = await window.games.getAll()
|
||||
gameLibrary.value = games.map((g: Game) => normalizeProxyModeForPreset({
|
||||
...g,
|
||||
status: 'idle',
|
||||
latency: 0
|
||||
}))
|
||||
// 默认选中第一个
|
||||
if (!currentGameId.value && gameLibrary.value.length > 0) {
|
||||
const firstGame = gameLibrary.value[0]
|
||||
if (firstGame) {
|
||||
currentGameId.value = firstGame.id
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load games:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加游戏
|
||||
async function addGame(game: Game) {
|
||||
game = normalizeProxyModeForPreset(game)
|
||||
// @ts-ignore
|
||||
const updatedGames = await window.games.save(toIpcGame(game))
|
||||
// 更新本地状态
|
||||
syncGames(updatedGames)
|
||||
}
|
||||
|
||||
// 更新游戏
|
||||
async function updateGame(game: Game) {
|
||||
game = normalizeProxyModeForPreset(game)
|
||||
// 乐观更新:立即更新本地状态
|
||||
const index = gameLibrary.value.findIndex(g => g.id === game.id)
|
||||
if (index !== -1) {
|
||||
gameLibrary.value[index] = { ...gameLibrary.value[index], ...game }
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const updatedGames = await window.games.save(toIpcGame(game))
|
||||
syncGames(updatedGames)
|
||||
} catch (e) {
|
||||
console.error('更新游戏失败:', e)
|
||||
// 如果失败,可能需要回滚(这里暂不实现复杂回滚,依赖 syncGames 下次同步)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除游戏
|
||||
async function removeGame(id: string) {
|
||||
// @ts-ignore
|
||||
const updatedGames = await window.games.delete(id)
|
||||
syncGames(updatedGames)
|
||||
if (currentGameId.value === id) {
|
||||
const firstGame = gameLibrary.value[0]
|
||||
currentGameId.value = (gameLibrary.value.length > 0 && firstGame) ? firstGame.id : null
|
||||
}
|
||||
}
|
||||
|
||||
// 同步状态(保留运行状态)
|
||||
function syncGames(remoteGames: Game[]) {
|
||||
gameLibrary.value = remoteGames.map((rg: Game) => {
|
||||
const existing = gameLibrary.value.find(lg => lg.id === rg.id)
|
||||
return normalizeProxyModeForPreset({
|
||||
...rg,
|
||||
status: existing?.status || 'idle',
|
||||
latency: existing?.latency || 0,
|
||||
nodeId: rg.nodeId || existing?.nodeId
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function setCurrentGame(id: string) {
|
||||
currentGameId.value = id
|
||||
const game = gameLibrary.value.find(g => g.id === id)
|
||||
if (game) {
|
||||
game.lastPlayed = Date.now()
|
||||
// 可选:更新 lastPlayed 到后端
|
||||
// @ts-ignore
|
||||
window.games.save(toIpcGame({ ...game, lastPlayed: Date.now() }))
|
||||
}
|
||||
}
|
||||
|
||||
function setGameStatus(id: string, status: 'idle' | 'accelerating') {
|
||||
const game = gameLibrary.value.find(g => g.id === id)
|
||||
if (game) {
|
||||
game.status = status
|
||||
}
|
||||
}
|
||||
|
||||
function updateLatency(id: string, ms: number) {
|
||||
const game = gameLibrary.value.find(g => g.id === id)
|
||||
if (game) {
|
||||
game.latency = ms
|
||||
}
|
||||
}
|
||||
|
||||
// 根据扫描到的进程名匹配游戏
|
||||
function matchRunningGames(processNames: string[]) {
|
||||
const matchedIds: string[] = []
|
||||
processNames.forEach(pName => {
|
||||
const pNameLower = pName.toLowerCase()
|
||||
gameLibrary.value.forEach(game => {
|
||||
const targets = Array.isArray(game.processName) ? game.processName : [game.processName]
|
||||
if (targets.some(t => t.toLowerCase() === pNameLower)) {
|
||||
if (!matchedIds.includes(game.id)) {
|
||||
matchedIds.push(game.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
runningGames.value = matchedIds
|
||||
return matchedIds
|
||||
}
|
||||
|
||||
// 启动加速
|
||||
async function startGame(id: string) {
|
||||
const game = gameLibrary.value.find(g => g.id === id)
|
||||
if (!game) return
|
||||
|
||||
const nodeStore = useNodeStore()
|
||||
const selectedNodeId = String(game.nodeId || '')
|
||||
const node = nodeStore.nodes.find(n => n.id === selectedNodeId || n.tag === selectedNodeId)
|
||||
|
||||
if (!node) {
|
||||
const msg = 'Selected node not found. Please reselect a node and try again.'
|
||||
console.error(msg, { gameId: id, nodeId: selectedNodeId })
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
try {
|
||||
const rawGame = toRaw(game) as Game
|
||||
const rawNode = toRaw(node) as any
|
||||
const config = String(generateSingboxConfig(rawGame, rawNode))
|
||||
// @ts-ignore
|
||||
await window.singbox.start(config)
|
||||
|
||||
const procs = Array.isArray(rawGame.processName) ? rawGame.processName.map(p => String(p)) : [String(rawGame.processName)]
|
||||
// @ts-ignore
|
||||
await window.proxyMonitor.start(String(rawGame.id), procs)
|
||||
|
||||
setGameStatus(id, 'accelerating')
|
||||
} catch (e) {
|
||||
console.error('启动加速失败:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// 停止加速
|
||||
async function stopGame(id: string) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await window.singbox.stop()
|
||||
// @ts-ignore
|
||||
await window.proxyMonitor.stop()
|
||||
|
||||
setGameStatus(id, 'idle')
|
||||
} catch (e) {
|
||||
console.error('停止加速失败:', e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化调用
|
||||
init()
|
||||
|
||||
return {
|
||||
gameLibrary,
|
||||
currentGameId,
|
||||
currentGame,
|
||||
runningGames,
|
||||
init,
|
||||
setCurrentGame,
|
||||
setGameStatus,
|
||||
updateLatency,
|
||||
matchRunningGames,
|
||||
addGame,
|
||||
updateGame,
|
||||
removeGame,
|
||||
startGame,
|
||||
stopGame
|
||||
}
|
||||
})
|
||||
|
||||
function toIpcGame(game: Game): Game {
|
||||
const raw = toRaw(game) as any
|
||||
|
||||
const processName = Array.isArray(raw.processName)
|
||||
? raw.processName.map((p: any) => String(p))
|
||||
: String(raw.processName ?? '')
|
||||
|
||||
const tags = Array.isArray(raw.tags) ? raw.tags.map((t: any) => String(t)) : undefined
|
||||
const routingRules = Array.isArray(raw.routingRules) ? raw.routingRules.map((r: any) => String(r)) : undefined
|
||||
|
||||
return {
|
||||
id: String(raw.id ?? ''),
|
||||
name: String(raw.name ?? ''),
|
||||
iconUrl: raw.iconUrl != null ? String(raw.iconUrl) : undefined,
|
||||
processName,
|
||||
category: String(raw.category ?? ''),
|
||||
tags,
|
||||
profileId: raw.profileId != null ? String(raw.profileId) : undefined,
|
||||
lastPlayed: typeof raw.lastPlayed === 'number' ? raw.lastPlayed : undefined,
|
||||
status: raw.status === 'accelerating' ? 'accelerating' : 'idle',
|
||||
latency: typeof raw.latency === 'number' ? raw.latency : 0,
|
||||
nodeId: raw.nodeId != null ? String(raw.nodeId) : undefined,
|
||||
proxyMode: raw.proxyMode === 'routing' ? 'routing' : 'process',
|
||||
routingRules,
|
||||
chainProxy: !!raw.chainProxy
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
@@ -0,0 +1,176 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, reactive } from 'vue'
|
||||
import { parseShareLink, parseBatchLinks, normalizeNodeType } from '@/utils/protocol'
|
||||
import type { NodeConfig } from '@/utils/protocol'
|
||||
import { useSettingsStore } from './settings'
|
||||
|
||||
type CheckMethod = 'ping' | 'tcp' | 'http'
|
||||
|
||||
export const useNodeStore = defineStore('nodes', () => {
|
||||
const nodes = ref<NodeConfig[]>([])
|
||||
const nodeStats = reactive<Record<string, { latency: number; loss: number }>>({})
|
||||
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
function normalizeNode(node: NodeConfig): NodeConfig {
|
||||
const normalizedType = normalizeNodeType(node.type)
|
||||
return {
|
||||
...node,
|
||||
type: normalizedType || String(node.type || '').trim().toLowerCase()
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNode(node: NodeConfig, methodOverride?: CheckMethod) {
|
||||
if (!node.server) return
|
||||
|
||||
const method = methodOverride || settingsStore.checkMethod
|
||||
let result = { latency: -1, loss: 0 }
|
||||
|
||||
try {
|
||||
if (method === 'tcp') {
|
||||
// @ts-ignore
|
||||
if (window.system?.tcpPing) {
|
||||
// @ts-ignore
|
||||
result = await window.system.tcpPing(node.server, node.server_port)
|
||||
} else {
|
||||
// Mock if not available
|
||||
result = await mockPing()
|
||||
}
|
||||
} else {
|
||||
// Ping
|
||||
// @ts-ignore
|
||||
if (window.system?.ping) {
|
||||
// @ts-ignore
|
||||
result = await window.system.ping(node.server)
|
||||
} else {
|
||||
// Mock
|
||||
result = await mockPing()
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Ping failed', e)
|
||||
result = { latency: -1, loss: 100 }
|
||||
}
|
||||
|
||||
// Update stats
|
||||
const key = node.id || node.tag
|
||||
if (key) {
|
||||
nodeStats[key] = result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Mock function for development if backend is not ready
|
||||
async function mockPing() {
|
||||
return new Promise<{ latency: number; loss: number }>((resolve) => {
|
||||
setTimeout(() => {
|
||||
const success = Math.random() > 0.1
|
||||
resolve({
|
||||
latency: success ? Math.floor(Math.random() * 200) + 20 : -1,
|
||||
loss: success ? 0 : 100
|
||||
})
|
||||
}, 500)
|
||||
})
|
||||
}
|
||||
|
||||
async function checkAllNodes() {
|
||||
// Limit concurrency
|
||||
const chunks = []
|
||||
const chunkSize = 5
|
||||
for (let i = 0; i < nodes.value.length; i += chunkSize) {
|
||||
chunks.push(nodes.value.slice(i, i + chunkSize))
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
await Promise.all(chunk.map(node => checkNode(node)))
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodes() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const loaded = await window.nodes.getAll()
|
||||
nodes.value = (loaded as NodeConfig[]).map(normalizeNode)
|
||||
} catch (e) {
|
||||
console.error('Failed to load nodes:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function addNode(link: string) {
|
||||
const node = parseShareLink(link)
|
||||
if (node) {
|
||||
try {
|
||||
const normalized = normalizeNode(node)
|
||||
// @ts-ignore
|
||||
const saved = await window.nodes.save(normalized)
|
||||
nodes.value = (saved as NodeConfig[]).map(normalizeNode)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to save node:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function saveNode(node: NodeConfig) {
|
||||
try {
|
||||
const normalized = normalizeNode(node)
|
||||
// @ts-ignore
|
||||
const saved = await window.nodes.save(normalized)
|
||||
nodes.value = (saved as NodeConfig[]).map(normalizeNode)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to save node:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function addNodes(content: string): Promise<number> {
|
||||
const newNodes = parseBatchLinks(content).map(normalizeNode)
|
||||
if (newNodes.length === 0) return 0
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const imported = await window.nodes.import(newNodes)
|
||||
nodes.value = (imported as NodeConfig[]).map(normalizeNode)
|
||||
// The import method on backend handles duplicates and returns all nodes
|
||||
// We can't easily count how many were added unless backend returns that info
|
||||
// But for now we just return parsed count
|
||||
return newNodes.length
|
||||
} catch (e) {
|
||||
console.error('Failed to import nodes:', e)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNode(id: string) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const remain = await window.nodes.delete(id)
|
||||
nodes.value = (remain as NodeConfig[]).map(normalizeNode)
|
||||
} catch (e) {
|
||||
console.error('Failed to delete node:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateNode(node: NodeConfig) {
|
||||
return saveNode(node)
|
||||
}
|
||||
|
||||
// Initial load
|
||||
loadNodes()
|
||||
|
||||
return {
|
||||
nodes,
|
||||
loadNodes,
|
||||
addNode,
|
||||
saveNode,
|
||||
addNodes,
|
||||
removeNode,
|
||||
updateNode,
|
||||
nodeStats,
|
||||
checkNode,
|
||||
checkAllNodes
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
export type CheckMethod = 'ping' | 'tcp' | 'http'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
const checkInterval = useLocalStorage('settings-check-interval', 5000)
|
||||
const checkMethod = useLocalStorage<CheckMethod>('settings-check-method', 'ping')
|
||||
const checkUrl = useLocalStorage('settings-check-url', 'http://www.gstatic.com/generate_204')
|
||||
|
||||
return {
|
||||
checkInterval,
|
||||
checkMethod,
|
||||
checkUrl
|
||||
}
|
||||
})
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-on-surface);
|
||||
}
|
||||
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Scrollbar customization */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-secondary);
|
||||
}
|
||||
|
||||
/*
|
||||
Fix for Naive UI Primary Button transparency issue in Light Mode
|
||||
caused by Tailwind Preflight or style conflicts.
|
||||
*/
|
||||
|
||||
/* Green Theme (Default) */
|
||||
html.theme-green .n-button.n-button--primary-type,
|
||||
html:not([class*="theme-"]) .n-button.n-button--primary-type {
|
||||
background-color: #3eaf7c !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #3eaf7c !important;
|
||||
}
|
||||
html.theme-green .n-button.n-button--primary-type:hover,
|
||||
html:not([class*="theme-"]) .n-button.n-button--primary-type:hover {
|
||||
background-color: #10b981 !important;
|
||||
border-color: #10b981 !important;
|
||||
}
|
||||
|
||||
/* Blue Theme */
|
||||
html.theme-blue .n-button.n-button--primary-type {
|
||||
background-color: #3b82f6 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #3b82f6 !important;
|
||||
}
|
||||
html.theme-blue .n-button.n-button--primary-type:hover {
|
||||
background-color: #2563eb !important;
|
||||
border-color: #2563eb !important;
|
||||
}
|
||||
|
||||
/* Purple Theme */
|
||||
html.theme-purple .n-button.n-button--primary-type {
|
||||
background-color: #8b5cf6 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #8b5cf6 !important;
|
||||
}
|
||||
html.theme-purple .n-button.n-button--primary-type:hover {
|
||||
background-color: #7c3aed !important;
|
||||
border-color: #7c3aed !important;
|
||||
}
|
||||
|
||||
/* Orange Theme */
|
||||
html.theme-orange .n-button.n-button--primary-type {
|
||||
background-color: #f97316 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #f97316 !important;
|
||||
}
|
||||
html.theme-orange .n-button.n-button--primary-type:hover {
|
||||
background-color: #ea580c !important;
|
||||
border-color: #ea580c !important;
|
||||
}
|
||||
|
||||
/* Red Theme */
|
||||
html.theme-red .n-button.n-button--primary-type {
|
||||
background-color: #ef4444 !important;
|
||||
color: #ffffff !important;
|
||||
border: 1px solid #ef4444 !important;
|
||||
}
|
||||
html.theme-red .n-button.n-button--primary-type:hover {
|
||||
background-color: #dc2626 !important;
|
||||
border-color: #dc2626 !important;
|
||||
}
|
||||
|
||||
/* Disabled state for Primary Button */
|
||||
.n-button.n-button--primary-type.n-button--disabled {
|
||||
opacity: 0.6 !important;
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
:root {
|
||||
/* Primitive Colors (Semantic Base) */
|
||||
--rgb-white: 255, 255, 255;
|
||||
--rgb-black: 0, 0, 0;
|
||||
|
||||
/* Status Colors (Default) */
|
||||
--rgb-success: 34, 197, 94; /* #22c55e */
|
||||
--rgb-success-bg: 220, 252, 231; /* #dcfce7 */
|
||||
--rgb-on-success: 255, 255, 255; /* #ffffff */
|
||||
|
||||
--rgb-warning: 234, 179, 8; /* #eab308 */
|
||||
--rgb-warning-bg: 254, 249, 195; /* #fef9c3 */
|
||||
--rgb-on-warning: 255, 255, 255; /* #ffffff */
|
||||
|
||||
--rgb-error: 239, 68, 68; /* #ef4444 */
|
||||
--rgb-error-bg: 254, 226, 226; /* #fee2e2 */
|
||||
--rgb-on-error: 255, 255, 255; /* #ffffff */
|
||||
|
||||
--rgb-info: 59, 130, 246; /* #3b82f6 */
|
||||
--rgb-info-bg: 219, 234, 254; /* #dbeafe */
|
||||
--rgb-on-info: 255, 255, 255; /* #ffffff */
|
||||
|
||||
/* Theme: Light (Default) */
|
||||
--rgb-primary: 62, 175, 124; /* #3eaf7c */
|
||||
--rgb-primary-hover: 16, 185, 129;/* #10b981 */
|
||||
--rgb-on-primary: 255, 255, 255; /* #ffffff */
|
||||
|
||||
--rgb-secondary: 100, 116, 139; /* #64748b */
|
||||
--rgb-secondary-hover: 71, 85, 105;/* #475569 */
|
||||
--rgb-on-secondary: 255, 255, 255;/* #ffffff */
|
||||
|
||||
--rgb-background: 255, 255, 255; /* #ffffff */
|
||||
--rgb-surface: 248, 250, 252; /* #f8fafc */
|
||||
--rgb-surface-panel: 255, 255, 255;/* #ffffff */
|
||||
--rgb-surface-overlay: 255, 255, 255;/* #ffffff */
|
||||
|
||||
--rgb-on-surface: 15, 23, 42; /* #0f172a */
|
||||
--rgb-on-surface-variant: 71, 85, 105; /* #475569 */
|
||||
--rgb-on-surface-muted: 148, 163, 184; /* #94a3b8 */
|
||||
|
||||
--rgb-border: 226, 232, 240; /* #e2e8f0 */
|
||||
--rgb-border-hover: 203, 213, 225;/* #cbd5e1 */
|
||||
|
||||
--rgb-overlay: 0, 0, 0;
|
||||
|
||||
/* CSS Variables for direct usage (Legacy/Fallback) */
|
||||
--color-primary: rgb(var(--rgb-primary));
|
||||
--color-background: rgb(var(--rgb-background));
|
||||
--color-surface: rgb(var(--rgb-surface));
|
||||
--color-on-surface: rgb(var(--rgb-on-surface));
|
||||
--color-border: rgb(var(--rgb-border));
|
||||
}
|
||||
|
||||
/* Theme Colors Override */
|
||||
html.theme-green {
|
||||
--rgb-primary: 62, 175, 124;
|
||||
--rgb-primary-hover: 16, 185, 129;
|
||||
}
|
||||
html.theme-blue {
|
||||
--rgb-primary: 59, 130, 246;
|
||||
--rgb-primary-hover: 37, 99, 235;
|
||||
}
|
||||
html.theme-purple {
|
||||
--rgb-primary: 139, 92, 246;
|
||||
--rgb-primary-hover: 124, 58, 237;
|
||||
}
|
||||
html.theme-orange {
|
||||
--rgb-primary: 249, 115, 22;
|
||||
--rgb-primary-hover: 234, 88, 12;
|
||||
}
|
||||
html.theme-red {
|
||||
--rgb-primary: 239, 68, 68;
|
||||
--rgb-primary-hover: 220, 38, 38;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
/* Theme: Dark */
|
||||
--rgb-on-primary: 255, 255, 255;
|
||||
|
||||
--rgb-secondary: 71, 85, 105; /* #475569 */
|
||||
--rgb-secondary-hover: 51, 65, 85;/* #334155 */
|
||||
--rgb-on-secondary: 248, 250, 252;/* #f8fafc */
|
||||
|
||||
--rgb-background: 15, 23, 42; /* #0f172a */
|
||||
--rgb-surface: 15, 23, 42; /* #0f172a */
|
||||
--rgb-surface-panel: 30, 41, 59; /* #1e293b */
|
||||
--rgb-surface-overlay: 51, 65, 85;/* #334155 */
|
||||
|
||||
--rgb-on-surface: 241, 245, 249; /* #f1f5f9 */
|
||||
--rgb-on-surface-variant: 148, 163, 184; /* #94a3b8 */
|
||||
--rgb-on-surface-muted: 71, 85, 105; /* #475569 */
|
||||
|
||||
--rgb-border: 51, 65, 85; /* #334155 */
|
||||
--rgb-border-hover: 71, 85, 105; /* #475569 */
|
||||
|
||||
--rgb-success: 74, 222, 128; /* #4ade80 */
|
||||
--rgb-warning: 250, 204, 21; /* #facc15 */
|
||||
--rgb-error: 248, 113, 113; /* #f87171 */
|
||||
--rgb-info: 96, 165, 250; /* #60a5fa */
|
||||
}
|
||||
|
||||
/* Transitions */
|
||||
*, *::before, *::after {
|
||||
transition: background-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
Vendored
+113
@@ -0,0 +1,113 @@
|
||||
export interface Game {
|
||||
id: string
|
||||
name: string
|
||||
iconUrl?: string
|
||||
processName: string | string[]
|
||||
category: string
|
||||
tags?: string[]
|
||||
profileId?: string
|
||||
lastPlayed?: number
|
||||
status?: 'idle' | 'accelerating'
|
||||
latency?: number
|
||||
nodeId?: string
|
||||
proxyMode?: 'process' | 'routing'
|
||||
routingRules?: string[]
|
||||
chainProxy?: boolean
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
parentId?: string
|
||||
rules?: string[]
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface ConfigProfile {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
rules: {
|
||||
type: 'process' | 'domain' | 'ip'
|
||||
value: string[]
|
||||
outbound: 'proxy' | 'direct' | 'block'
|
||||
}[]
|
||||
chainProxy: boolean
|
||||
}
|
||||
|
||||
export interface ProcessInfo {
|
||||
pid: number
|
||||
ppid: number
|
||||
name: string
|
||||
path?: string
|
||||
children?: ProcessInfo[]
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface ElectronAPI {
|
||||
minimize: () => Promise<void>
|
||||
maximize: () => Promise<void>
|
||||
close: () => Promise<void>
|
||||
pickImage: () => Promise<string | null>
|
||||
pickProcess: () => Promise<string[] | null>
|
||||
pickProcessFolder: (maxDepth?: number) => Promise<string[] | null>
|
||||
on: (channel: string, callback: (...args: any[]) => void) => void
|
||||
off: (channel: string, callback: (...args: any[]) => void) => void
|
||||
}
|
||||
|
||||
interface SingBoxAPI {
|
||||
start: (config: string) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
restart: (config: string) => Promise<void>
|
||||
}
|
||||
|
||||
interface GamesAPI {
|
||||
getAll: () => Promise<Game[]>
|
||||
save: (game: Game) => Promise<Game[]>
|
||||
delete: (id: string) => Promise<Game[]>
|
||||
}
|
||||
|
||||
interface CategoriesAPI {
|
||||
getAll: () => Promise<Category[]>
|
||||
save: (category: Category) => Promise<Category[]>
|
||||
delete: (id: string) => Promise<Category[]>
|
||||
}
|
||||
|
||||
interface ProfilesAPI {
|
||||
getAll: () => Promise<ConfigProfile[]>
|
||||
save: (profile: ConfigProfile) => Promise<ConfigProfile[]>
|
||||
delete: (id: string) => Promise<ConfigProfile[]>
|
||||
}
|
||||
|
||||
interface SystemAPI {
|
||||
scanProcesses: () => Promise<string[]>
|
||||
getProcessTree: () => Promise<ProcessInfo[]>
|
||||
ping: (host: string) => Promise<{ latency: number; loss: number }>
|
||||
tcpPing: (host: string, port: number) => Promise<{ latency: number; loss: number }>
|
||||
}
|
||||
|
||||
interface ProxyMonitorAPI {
|
||||
start: (gameId: string, processNames: string[]) => Promise<void>
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
interface NodesAPI {
|
||||
getAll: () => Promise<any[]>
|
||||
save: (node: any) => Promise<any[]>
|
||||
delete: (id: string) => Promise<any[]>
|
||||
import: (nodes: any[]) => Promise<any[]>
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
singbox: SingBoxAPI
|
||||
games: GamesAPI
|
||||
categories: CategoriesAPI
|
||||
profiles: ProfilesAPI
|
||||
system: SystemAPI
|
||||
proxyMonitor: ProxyMonitorAPI
|
||||
nodes: NodesAPI
|
||||
}
|
||||
}
|
||||
|
||||
export { }
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseShareLink } from './protocol'
|
||||
import { Base64 } from 'js-base64'
|
||||
|
||||
describe('Protocol Parsing', () => {
|
||||
it('should parse VMess link', () => {
|
||||
const config = {
|
||||
v: "2",
|
||||
ps: "Test Node",
|
||||
add: "example.com",
|
||||
port: "443",
|
||||
id: "uuid",
|
||||
aid: "0",
|
||||
net: "ws",
|
||||
type: "none",
|
||||
host: "example.com",
|
||||
path: "/",
|
||||
tls: "tls"
|
||||
}
|
||||
const link = 'vmess://' + Base64.encode(JSON.stringify(config))
|
||||
const result = parseShareLink(link)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.type).toBe('vmess')
|
||||
expect(result?.server).toBe('example.com')
|
||||
expect(result?.tls?.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('should return null for invalid link', () => {
|
||||
expect(parseShareLink('invalid://link')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,339 @@
|
||||
import { Base64 } from 'js-base64'
|
||||
|
||||
export interface NodeConfig {
|
||||
id?: string // Added ID for management
|
||||
type: string
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
uuid?: string
|
||||
password?: string
|
||||
method?: string
|
||||
plugin?: string
|
||||
plugin_opts?: string
|
||||
network?: string
|
||||
security?: string
|
||||
path?: string
|
||||
host?: string
|
||||
tls?: {
|
||||
enabled: boolean
|
||||
server_name?: string
|
||||
insecure?: boolean
|
||||
utls?: {
|
||||
enabled: boolean
|
||||
fingerprint: string
|
||||
}
|
||||
}
|
||||
flow?: string
|
||||
packet_encoding?: string
|
||||
}
|
||||
|
||||
export function normalizeNodeType(type: string | null | undefined): string {
|
||||
const t = String(type ?? '').trim().toLowerCase()
|
||||
if (t === 'ss' || t === 'shadowsocks') return 'shadowsocks'
|
||||
return t
|
||||
}
|
||||
|
||||
export function parseShareLink(link: string): NodeConfig | null {
|
||||
link = link.trim()
|
||||
if (!link) return null
|
||||
|
||||
// Try to decode if it looks like a base64 string (no protocol prefix, no spaces)
|
||||
if (!link.includes('://') && /^[A-Za-z0-9+/=]+$/.test(link)) {
|
||||
try {
|
||||
// This might be a subscription content (multiple links)
|
||||
// But parseShareLink is designed for single link.
|
||||
// We will handle bulk import in a separate function or upper layer.
|
||||
// However, if it's a single base64 string, it might be a single node (rare for vmess/vless, common for SS)
|
||||
// or a subscription response.
|
||||
|
||||
// Let's assume the caller handles bulk splitting if it's a subscription.
|
||||
// But if the user pastes a raw base64 string which decodes to "vmess://...", we should handle it.
|
||||
const decoded = Base64.decode(link).trim()
|
||||
if (decoded.includes('://')) {
|
||||
return parseShareLink(decoded)
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (link.startsWith('vmess://')) {
|
||||
return parseVMess(link)
|
||||
} else if (link.startsWith('vless://')) {
|
||||
return parseVLESS(link)
|
||||
} else if (link.startsWith('ss://')) {
|
||||
return parseShadowsocks(link)
|
||||
} else if (link.startsWith('trojan://')) {
|
||||
return parseTrojan(link)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function parseBatchLinks(content: string): NodeConfig[] {
|
||||
content = content.trim()
|
||||
if (!content) return []
|
||||
|
||||
// Try Base64 decode first if the whole content looks like Base64
|
||||
if (!content.includes('://') && /^[A-Za-z0-9+/=\s]+$/.test(content)) {
|
||||
try {
|
||||
const decoded = Base64.decode(content)
|
||||
// If decoding yields readable characters and contains protocols
|
||||
if (decoded.includes('://')) {
|
||||
content = decoded
|
||||
}
|
||||
} catch (e) {
|
||||
// Not a valid base64 or not subscription content
|
||||
}
|
||||
}
|
||||
|
||||
const lines = content.split(/[\r\n]+/)
|
||||
const configs: NodeConfig[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const config = parseShareLink(line)
|
||||
if (config) {
|
||||
configs.push(config)
|
||||
}
|
||||
}
|
||||
|
||||
return configs
|
||||
}
|
||||
|
||||
function parseVMess(link: string): NodeConfig | null {
|
||||
try {
|
||||
const base64 = link.replace('vmess://', '')
|
||||
const jsonStr = Base64.decode(base64)
|
||||
const config = JSON.parse(jsonStr)
|
||||
return {
|
||||
type: 'vmess',
|
||||
tag: config.ps || 'VMess Node',
|
||||
server: config.add,
|
||||
server_port: Number(config.port),
|
||||
uuid: config.id,
|
||||
security: 'auto',
|
||||
network: config.net,
|
||||
path: config.path,
|
||||
host: config.host,
|
||||
tls: {
|
||||
enabled: config.tls === 'tls',
|
||||
server_name: config.host
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse VMess', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseVLESS(link: string): NodeConfig | null {
|
||||
try {
|
||||
const url = new URL(link)
|
||||
const params = url.searchParams
|
||||
return {
|
||||
type: 'vless',
|
||||
tag: decodeURIComponent(url.hash.slice(1)) || 'VLESS Node',
|
||||
server: url.hostname,
|
||||
server_port: Number(url.port),
|
||||
uuid: url.username,
|
||||
network: params.get('type') || 'tcp',
|
||||
security: params.get('security') || 'none',
|
||||
flow: params.get('flow') || undefined,
|
||||
tls: {
|
||||
enabled: params.get('security') === 'tls' || params.get('security') === 'reality',
|
||||
server_name: params.get('sni') || undefined,
|
||||
insecure: params.get('allowInsecure') === '1',
|
||||
utls: {
|
||||
enabled: !!params.get('fp'),
|
||||
fingerprint: params.get('fp') || 'chrome'
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse VLESS', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseShadowsocks(link: string): NodeConfig | null {
|
||||
try {
|
||||
const raw = link.replace(/^ss:\/\//, '')
|
||||
const hashPos = raw.indexOf('#')
|
||||
const withoutHash = hashPos >= 0 ? raw.slice(0, hashPos) : raw
|
||||
const hash = hashPos >= 0 ? raw.slice(hashPos + 1) : ''
|
||||
const tag = hash ? decodeURIComponent(hash) : 'Shadowsocks Node'
|
||||
|
||||
const qPos = withoutHash.indexOf('?')
|
||||
const mainPart = qPos >= 0 ? withoutHash.slice(0, qPos) : withoutHash
|
||||
const query = qPos >= 0 ? withoutHash.slice(qPos + 1) : ''
|
||||
let decodedMain = mainPart
|
||||
|
||||
// SIP002 allows `ss://base64(method:password@host:port)` and URL-safe base64.
|
||||
if (!decodedMain.includes('@')) {
|
||||
decodedMain = decodeBase64Url(decodedMain)
|
||||
} else {
|
||||
const [encodedUserInfo = '', hostPort = ''] = decodedMain.split('@', 2)
|
||||
if (encodedUserInfo && !encodedUserInfo.includes(':')) {
|
||||
decodedMain = `${decodeBase64Url(encodedUserInfo)}@${hostPort}`
|
||||
}
|
||||
}
|
||||
|
||||
const at = decodedMain.lastIndexOf('@')
|
||||
if (at <= 0) return null
|
||||
const userPart = decodedMain.slice(0, at)
|
||||
const serverPart = decodedMain.slice(at + 1)
|
||||
|
||||
const colon = userPart.indexOf(':')
|
||||
if (colon <= 0) return null
|
||||
|
||||
const method = decodeURIComponent(userPart.slice(0, colon))
|
||||
const password = decodeURIComponent(userPart.slice(colon + 1))
|
||||
|
||||
const serverColon = serverPart.lastIndexOf(':')
|
||||
if (serverColon <= 0) return null
|
||||
const server = decodeURIComponent(serverPart.slice(0, serverColon))
|
||||
const serverPort = Number(serverPart.slice(serverColon + 1))
|
||||
if (!Number.isFinite(serverPort) || serverPort <= 0) return null
|
||||
|
||||
const params = new URLSearchParams(query)
|
||||
const pluginValue = params.get('plugin') || ''
|
||||
const [pluginNameRaw = '', ...pluginOptsRaw] = pluginValue.split(';')
|
||||
const pluginName = pluginNameRaw ? decodeURIComponent(pluginNameRaw) : undefined
|
||||
const pluginOpts = pluginOptsRaw.length > 0
|
||||
? pluginOptsRaw.map(v => decodeURIComponent(v)).join(';')
|
||||
: undefined
|
||||
|
||||
return {
|
||||
type: 'shadowsocks',
|
||||
tag: tag || 'Shadowsocks Node',
|
||||
server,
|
||||
server_port: serverPort,
|
||||
method,
|
||||
password,
|
||||
plugin: pluginName,
|
||||
plugin_opts: pluginOpts
|
||||
}
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(input: string): string {
|
||||
const normalized = input.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
|
||||
return Base64.decode(padded)
|
||||
}
|
||||
|
||||
function parseTrojan(link: string): NodeConfig | null {
|
||||
try {
|
||||
const url = new URL(link)
|
||||
const params = url.searchParams
|
||||
return {
|
||||
type: 'trojan',
|
||||
tag: decodeURIComponent(url.hash.slice(1)) || 'Trojan Node',
|
||||
server: url.hostname,
|
||||
server_port: Number(url.port),
|
||||
password: url.username,
|
||||
network: params.get('type') || 'tcp',
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: params.get('sni') || undefined,
|
||||
insecure: params.get('allowInsecure') === '1'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function generateBatchLinks(nodes: NodeConfig[]): string {
|
||||
const links = nodes.map(generateShareLink).filter(Boolean) as string[]
|
||||
return Base64.encode(links.join('\n'))
|
||||
}
|
||||
|
||||
export function generateShareLink(node: NodeConfig): string | null {
|
||||
switch (normalizeNodeType(node.type)) {
|
||||
case 'vmess': return generateVMess(node)
|
||||
case 'vless': return generateVLESS(node)
|
||||
case 'shadowsocks':
|
||||
case 'ss': return generateShadowsocks(node)
|
||||
case 'trojan': return generateTrojan(node)
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function generateVMess(node: NodeConfig): string {
|
||||
const config = {
|
||||
v: "2",
|
||||
ps: node.tag,
|
||||
add: node.server,
|
||||
port: node.server_port,
|
||||
id: node.uuid,
|
||||
aid: 0,
|
||||
scy: node.security || "auto",
|
||||
net: node.network || "tcp",
|
||||
type: "none",
|
||||
host: node.host || "",
|
||||
path: node.path || "",
|
||||
tls: node.tls?.enabled ? "tls" : ""
|
||||
}
|
||||
return 'vmess://' + Base64.encode(JSON.stringify(config))
|
||||
}
|
||||
|
||||
function generateVLESS(node: NodeConfig): string {
|
||||
const uuid = node.uuid || ''
|
||||
// VLESS format: vless://uuid@host:port?params#tag
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (node.network) params.set('type', node.network)
|
||||
if (node.security) params.set('security', node.security)
|
||||
if (node.flow) params.set('flow', node.flow)
|
||||
|
||||
if (node.tls?.enabled) {
|
||||
if (!node.security || node.security === 'none') {
|
||||
// If tls is enabled but security not set, assume tls unless explicit
|
||||
if (node.security !== 'reality') params.set('security', 'tls')
|
||||
}
|
||||
if (node.tls.server_name) params.set('sni', node.tls.server_name)
|
||||
if (node.tls.insecure) params.set('allowInsecure', '1')
|
||||
if (node.tls.utls?.enabled && node.tls.utls.fingerprint) {
|
||||
params.set('fp', node.tls.utls.fingerprint)
|
||||
}
|
||||
}
|
||||
|
||||
const hash = '#' + encodeURIComponent(node.tag)
|
||||
return `vless://${uuid}@${node.server}:${node.server_port}?${params.toString()}${hash}`
|
||||
}
|
||||
|
||||
function generateShadowsocks(node: NodeConfig): string {
|
||||
const method = node.method || 'chacha20-ietf-poly1305'
|
||||
const password = node.password || ''
|
||||
|
||||
// SS URI SIP002: ss://userinfo@host:port?plugin=...#tag
|
||||
// userinfo = base64(method:password)
|
||||
const userInfo = Base64.encode(`${method}:${password}`)
|
||||
let link = `ss://${userInfo}@${node.server}:${node.server_port}`
|
||||
|
||||
if (node.plugin) {
|
||||
let pluginStr = node.plugin
|
||||
if (node.plugin_opts) {
|
||||
pluginStr += ';' + node.plugin_opts
|
||||
}
|
||||
link += `/?plugin=${encodeURIComponent(pluginStr)}`
|
||||
}
|
||||
|
||||
link += '#' + encodeURIComponent(node.tag)
|
||||
return link
|
||||
}
|
||||
|
||||
function generateTrojan(node: NodeConfig): string {
|
||||
const password = node.password || ''
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (node.network) params.set('type', node.network)
|
||||
if (node.tls?.server_name) params.set('sni', node.tls.server_name)
|
||||
if (node.tls?.insecure) params.set('allowInsecure', '1')
|
||||
|
||||
const hash = '#' + encodeURIComponent(node.tag)
|
||||
return `trojan://${password}@${node.server}:${node.server_port}?${params.toString()}${hash}`
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { Game } from '@/types'
|
||||
import type { NodeConfig } from '@/utils/protocol'
|
||||
|
||||
interface SingboxConfig {
|
||||
log: {
|
||||
level: string
|
||||
timestamp: boolean
|
||||
}
|
||||
dns: {
|
||||
servers: any[]
|
||||
rules: any[]
|
||||
final?: string
|
||||
}
|
||||
inbounds: any[]
|
||||
outbounds: any[]
|
||||
route: {
|
||||
rules: any[]
|
||||
auto_detect_interface: boolean
|
||||
final?: string
|
||||
}
|
||||
experimental?: {
|
||||
cache_file?: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function generateSingboxConfig(game: Game, node: NodeConfig): string {
|
||||
const isRoutingMode = game.proxyMode === 'routing'
|
||||
const processes = normalizeProcessNames(Array.isArray(game.processName) ? game.processName : [game.processName])
|
||||
const googleDomains = ['google.com', 'youtube.com', 'gstatic.com', 'googleapis.com', 'ytimg.com', 'ggpht.com']
|
||||
const googleKeywords = ['google', 'youtube']
|
||||
|
||||
// Basic Config Structure
|
||||
const config: SingboxConfig = {
|
||||
log: {
|
||||
level: 'info',
|
||||
timestamp: true
|
||||
},
|
||||
dns: {
|
||||
servers: [
|
||||
// Use encrypted DNS over HTTPS through proxy to reduce DNS poisoning risk.
|
||||
{ tag: 'google', address: 'https://dns.google/dns-query', detour: 'proxy', strategy: 'ipv4_only' },
|
||||
{ tag: 'cloudflare', address: 'https://1.1.1.1/dns-query', detour: 'proxy', strategy: 'ipv4_only' },
|
||||
{ tag: 'local', address: 'local', detour: 'direct', strategy: 'ipv4_only' },
|
||||
{ tag: 'block', address: 'rcode://success' }
|
||||
],
|
||||
rules: isRoutingMode ? [] : [
|
||||
{ outbound: 'any', server: 'local' }
|
||||
],
|
||||
final: isRoutingMode ? 'google' : 'local'
|
||||
},
|
||||
inbounds: [
|
||||
{
|
||||
type: 'tun',
|
||||
tag: 'tun-in',
|
||||
interface_name: 'singbox-tun',
|
||||
inet4_address: '172.19.0.1/30',
|
||||
mtu: 1280,
|
||||
auto_route: true,
|
||||
strict_route: false, // Set to true if you want to prevent leaks absolutely, but false is safer for compatibility
|
||||
stack: 'system', // 'gvisor' or 'system' or 'mixed'
|
||||
sniff: true
|
||||
}
|
||||
],
|
||||
outbounds: [
|
||||
{
|
||||
type: 'selector',
|
||||
tag: 'proxy',
|
||||
outbounds: ['node-out', 'direct'],
|
||||
default: 'node-out'
|
||||
},
|
||||
{
|
||||
type: 'direct',
|
||||
tag: 'direct'
|
||||
},
|
||||
{
|
||||
type: 'block',
|
||||
tag: 'block'
|
||||
},
|
||||
{
|
||||
type: 'dns',
|
||||
tag: 'dns-out'
|
||||
}
|
||||
],
|
||||
route: {
|
||||
rules: [
|
||||
{ protocol: 'dns', outbound: 'dns-out' }
|
||||
],
|
||||
auto_detect_interface: true,
|
||||
final: isRoutingMode ? 'proxy' : 'direct'
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Convert Node to Outbound
|
||||
const nodeOutbound = convertNodeToOutbound(node)
|
||||
config.outbounds.push(nodeOutbound)
|
||||
|
||||
// 2. Configure DNS Rules
|
||||
// In process mode, avoid global domain DNS detours to prevent affecting other apps.
|
||||
if (isRoutingMode) {
|
||||
config.dns.rules.unshift({
|
||||
domain_suffix: googleDomains,
|
||||
domain_keyword: googleKeywords,
|
||||
server: 'google'
|
||||
})
|
||||
}
|
||||
|
||||
if (processes.length > 0) {
|
||||
config.dns.rules.unshift({
|
||||
process_name: processes,
|
||||
server: 'google'
|
||||
})
|
||||
}
|
||||
|
||||
// 3. Configure Route Rules
|
||||
if (isRoutingMode) {
|
||||
config.route.rules.push({
|
||||
domain_suffix: googleDomains,
|
||||
domain_keyword: googleKeywords,
|
||||
outbound: 'proxy'
|
||||
})
|
||||
}
|
||||
|
||||
if (isRoutingMode) {
|
||||
// Handle "Bypass Mainland China" or "Global" based on routing_rules
|
||||
const routingRules = game.routingRules || []
|
||||
|
||||
if (routingRules.includes('bypass_cn')) {
|
||||
// Bypass Mainland China Rules
|
||||
config.route.rules.push({
|
||||
ip_is_private: true,
|
||||
outbound: 'direct'
|
||||
})
|
||||
config.route.rules.push({
|
||||
domain_suffix: ['cn'],
|
||||
outbound: 'direct'
|
||||
})
|
||||
config.route.rules.push({
|
||||
domain_suffix: [
|
||||
'qq.com', 'baidu.com', 'bilibili.com', 'taobao.com', 'tmall.com',
|
||||
'jd.com', 'douyin.com', 'douban.com', 'weibo.com', 'zhihu.com',
|
||||
'youku.com', 'iqiyi.com', '163.com', 'sina.com.cn', 'alicdn.com'
|
||||
],
|
||||
outbound: 'direct'
|
||||
})
|
||||
// Default final is 'proxy' (set in config.route.final)
|
||||
} else {
|
||||
// Global Mode (or default routing)
|
||||
// Default final is 'proxy'
|
||||
}
|
||||
} else {
|
||||
// Process Mode: Only Game Process -> Proxy, Default -> Direct
|
||||
if (processes.length > 0) {
|
||||
config.route.rules.push({
|
||||
process_name: processes,
|
||||
outbound: 'proxy'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(config, null, 2)
|
||||
}
|
||||
|
||||
function convertNodeToOutbound(node: NodeConfig): any {
|
||||
const base = {
|
||||
tag: 'node-out',
|
||||
server: node.server,
|
||||
server_port: node.server_port
|
||||
}
|
||||
|
||||
switch (node.type) {
|
||||
case 'ss':
|
||||
case 'shadowsocks':
|
||||
return {
|
||||
...base,
|
||||
type: 'shadowsocks',
|
||||
method: node.method,
|
||||
password: node.password,
|
||||
plugin: node.plugin,
|
||||
plugin_opts: node.plugin_opts
|
||||
}
|
||||
case 'vmess':
|
||||
return {
|
||||
...base,
|
||||
type: 'vmess',
|
||||
uuid: node.uuid,
|
||||
security: node.security || 'auto',
|
||||
alter_id: 0,
|
||||
transport: node.network ? {
|
||||
type: node.network,
|
||||
path: node.path,
|
||||
headers: node.host ? { Host: node.host } : undefined
|
||||
} : undefined,
|
||||
tls: node.tls?.enabled ? {
|
||||
enabled: true,
|
||||
server_name: node.tls.server_name || node.host,
|
||||
insecure: node.tls.insecure,
|
||||
utls: node.tls.utls?.enabled ? {
|
||||
enabled: true,
|
||||
fingerprint: node.tls.utls.fingerprint
|
||||
} : undefined
|
||||
} : undefined
|
||||
}
|
||||
case 'vless':
|
||||
return {
|
||||
...base,
|
||||
type: 'vless',
|
||||
uuid: node.uuid,
|
||||
flow: node.flow,
|
||||
packet_encoding: node.packet_encoding,
|
||||
transport: node.network ? {
|
||||
type: node.network,
|
||||
path: node.path,
|
||||
headers: node.host ? { Host: node.host } : undefined
|
||||
} : undefined,
|
||||
tls: node.tls?.enabled ? {
|
||||
enabled: true,
|
||||
server_name: node.tls.server_name || node.host,
|
||||
insecure: node.tls.insecure,
|
||||
utls: node.tls.utls?.enabled ? {
|
||||
enabled: true,
|
||||
fingerprint: node.tls.utls.fingerprint
|
||||
} : undefined
|
||||
} : undefined
|
||||
}
|
||||
case 'trojan':
|
||||
return {
|
||||
...base,
|
||||
type: 'trojan',
|
||||
password: node.password,
|
||||
transport: node.network ? {
|
||||
type: node.network,
|
||||
path: node.path,
|
||||
headers: node.host ? { Host: node.host } : undefined
|
||||
} : undefined,
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: node.tls?.server_name || node.host || node.server,
|
||||
insecure: node.tls?.insecure,
|
||||
utls: node.tls?.utls?.enabled ? {
|
||||
enabled: true,
|
||||
fingerprint: node.tls.utls.fingerprint
|
||||
} : undefined
|
||||
}
|
||||
}
|
||||
default:
|
||||
// Fallback or Error
|
||||
return {
|
||||
...base,
|
||||
type: 'socks', // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProcessNames(processes: string[]): string[] {
|
||||
const items = processes
|
||||
.map(p => String(p ?? '').trim())
|
||||
.filter(Boolean)
|
||||
|
||||
const set = new Set<string>()
|
||||
items.forEach(p => {
|
||||
set.add(p)
|
||||
const lower = p.toLowerCase()
|
||||
if (lower !== p) set.add(lower)
|
||||
})
|
||||
|
||||
return Array.from(set)
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
<template>
|
||||
<div
|
||||
class="h-full relative overflow-hidden bg-background text-on-surface flex flex-col transition-colors duration-300">
|
||||
<!-- Ambient Background Effects -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div
|
||||
class="absolute top-[-20%] left-[-10%] w-[60%] h-[60%] bg-primary/5 rounded-full blur-[120px] animate-pulse-slow">
|
||||
</div>
|
||||
<div
|
||||
class="absolute bottom-[-20%] right-[-10%] w-[50%] h-[50%] bg-secondary/5 rounded-full blur-[100px] animate-pulse-slow"
|
||||
style="animation-delay: 2s;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Container -->
|
||||
<div v-if="!game" class="relative z-10 flex-1 flex flex-col items-center justify-center p-8 text-center">
|
||||
<!-- Empty State (No Game Selected) -->
|
||||
<div class="relative mb-8 group">
|
||||
<div
|
||||
class="absolute inset-0 bg-primary/20 blur-3xl rounded-full scale-110 group-hover:scale-125 transition-transform duration-700">
|
||||
</div>
|
||||
<div
|
||||
class="relative z-10 w-32 h-32 md:w-40 md:h-40 bg-surface-panel/50 backdrop-blur-xl border border-border rounded-[2rem] flex items-center justify-center shadow-2xl transition-all duration-300 group-hover:-translate-y-2">
|
||||
<div class="i-material-symbols-sports-esports text-6xl md:text-7xl text-primary/80"></div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-on-surface mb-3 tracking-tight">
|
||||
{{ $t('games.no_game_selected') }}
|
||||
</h2>
|
||||
<p class="text-on-surface-muted max-w-sm mb-8 text-sm md:text-base leading-relaxed">
|
||||
{{ $t('games.select_game_desc') }}
|
||||
</p>
|
||||
<button @click="$router.push('/games')"
|
||||
class="px-8 py-3 bg-primary hover:bg-primary-hover text-on-primary rounded-xl font-bold shadow-lg shadow-primary/20 transition-all hover:scale-105 active:scale-95 flex items-center gap-2">
|
||||
<div class="i-material-symbols-library-books text-xl"></div>
|
||||
{{ $t('games.go_library') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="relative z-10 flex-1 flex flex-col h-full min-h-0">
|
||||
<!-- Header: Minimal Game Info -->
|
||||
<header
|
||||
class="flex-none h-16 px-6 flex items-center justify-between border-b border-border/50 bg-surface/80 backdrop-blur-md z-20">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-surface-panel border border-border overflow-hidden flex items-center justify-center shadow-sm">
|
||||
<img v-if="game.iconUrl" :src="game.iconUrl" class="w-full h-full object-cover" />
|
||||
<div v-else class="i-material-symbols-sports-esports text-xl text-primary/60"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-lg font-bold leading-tight text-on-surface">{{ game.name }}</h1>
|
||||
<div class="flex items-center gap-2 text-xs text-on-surface-muted">
|
||||
<span
|
||||
class="bg-surface-overlay px-1.5 py-0.5 rounded border border-border data-[active=true]:text-primary data-[active=true]:border-primary/20"
|
||||
:data-active="isRunning">
|
||||
{{ categoryLabel }}
|
||||
</span>
|
||||
<span class="w-1 h-1 rounded-full bg-border"></span>
|
||||
<span :class="isRunning ? 'text-success font-medium' : 'text-on-surface-muted'">
|
||||
{{ isRunning ? $t('common.running') : $t('common.stopped') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Actions -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div v-if="isRunning"
|
||||
class="flex items-center gap-2 px-3 py-1 bg-success/10 border border-success/20 rounded-full">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-success animate-pulse"></div>
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-success">Live</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Main Dashboard Area (Left) -->
|
||||
<main class="flex-1 overflow-y-auto custom-scrollbar p-6 lg:p-10 flex flex-col gap-8 relative">
|
||||
|
||||
<!-- 1. Hero Section: The Status Core -->
|
||||
<section class="flex flex-col items-center justify-center min-h-[450px] flex-1 py-10 relative">
|
||||
|
||||
<!-- HUD Status Card (Floating) -->
|
||||
<div v-if="isRunning"
|
||||
class="absolute top-0 w-full max-w-2xl mx-auto flex justify-center animate-fade-in-up md:static md:mb-12">
|
||||
<div
|
||||
class="flex items-center gap-8 md:gap-12 px-8 py-4 bg-surface-panel/40 backdrop-blur-lg border border-border/50 rounded-2xl shadow-sm">
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-[10px] uppercase font-bold text-on-surface-muted tracking-widest mb-1">{{
|
||||
$t('games.packet_loss') }}</span>
|
||||
<span class="text-2xl font-mono font-bold text-on-surface tabular-nums">{{ currentLoss
|
||||
}}%</span>
|
||||
</div>
|
||||
<div class="w-px h-8 bg-border"></div>
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-[10px] uppercase font-bold text-on-surface-muted tracking-widest mb-1">{{
|
||||
$t('games.duration') }}</span>
|
||||
<span class="text-2xl font-mono font-bold text-on-surface tabular-nums">{{ durationFormatted
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Center -->
|
||||
<div class="relative z-10 flex flex-col items-center">
|
||||
<!-- Latency Big Display -->
|
||||
<div v-if="isRunning" class="mb-12 text-center animate-scale-in">
|
||||
<div class="flex items-baseline justify-center gap-3 relative">
|
||||
<span
|
||||
class="text-8xl md:text-9xl font-black tabular-nums tracking-tighter drop-shadow-sm transition-colors duration-300"
|
||||
:class="getLatencyTextColor(currentLatency)">
|
||||
{{ currentLatency > 0 ? currentLatency : '---' }}
|
||||
</span>
|
||||
<span
|
||||
class="text-xl font-bold text-on-surface-muted uppercase tracking-widest absolute -right-10 bottom-5">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Action Button -->
|
||||
<div class="relative group">
|
||||
<!-- Outer Glow Ring -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full bg-gradient-to-t from-primary/0 via-primary/30 to-primary/0 md:scale-150 animate-spin-slow opacity-0 group-hover:opacity-100 transition-opacity duration-700 pointer-events-none"
|
||||
:class="isRunning ? 'via-error/30' : 'via-primary/30'">
|
||||
</div>
|
||||
|
||||
<!-- Pulse Effect -->
|
||||
<div v-if="!isRunning"
|
||||
class="absolute inset-0 rounded-full bg-primary/20 animate-ping opacity-20 duration-[3s]">
|
||||
</div>
|
||||
|
||||
<button @click="toggleAccelerator"
|
||||
class="relative w-40 h-40 md:w-48 md:h-48 rounded-full flex items-center justify-center group outline-none transition-all duration-500 ease-out hover:scale-105 active:scale-95 z-10"
|
||||
:class="isRunning ? 'shadow-[0_0_60px_-10px_rgba(var(--rgb-error),0.3)]' : 'shadow-[0_0_60px_-10px_rgba(var(--rgb-primary),0.3)]'">
|
||||
|
||||
<!-- Button Background -->
|
||||
<div
|
||||
class="absolute inset-2 rounded-full border-4 backdrop-blur-md transition-all duration-500 overflow-hidden"
|
||||
:class="isRunning
|
||||
? 'bg-error/5 border-error/20 group-hover:bg-error/10 group-hover:border-error/40'
|
||||
: 'bg-primary/5 border-primary/20 group-hover:bg-primary/10 group-hover:border-primary/40'">
|
||||
<!-- Shiny reflection -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-white/40 to-transparent opacity-0 group-hover:opacity-30 transition-opacity duration-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Icon & Label -->
|
||||
<div class="flex flex-col items-center gap-3 relative z-20">
|
||||
<div
|
||||
class="text-5xl md:text-6xl transition-all duration-300 transform group-hover:scale-110"
|
||||
:class="isRunning ? 'i-carbon-stop-filled text-error drop-shadow-[0_2px_10px_rgba(var(--rgb-error),0.4)]' : 'i-carbon-play-filled-alt text-primary drop-shadow-[0_2px_10px_rgba(var(--rgb-primary),0.4)]'">
|
||||
</div>
|
||||
<span class="text-xs font-black uppercase tracking-[0.25em] transition-colors"
|
||||
:class="isRunning ? 'text-error' : 'text-primary'">
|
||||
{{ isRunning ? $t('common.stop') : $t('common.start') }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="mt-10 text-sm font-medium max-w-xs text-center leading-relaxed transition-colors"
|
||||
:class="isRunning ? 'text-success' : 'text-on-surface-muted'">
|
||||
{{ isRunning ? $t('dashboard.active_protection') : $t('dashboard.ready_to_boost') }}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. Secondary Info: Charts -->
|
||||
<section v-if="isRunning"
|
||||
class="h-48 w-full max-w-4xl mx-auto opacity-90 hover:opacity-100 transition-opacity">
|
||||
<div
|
||||
class="w-full h-full bg-surface-panel/40 border border-border rounded-2xl p-4 relative overflow-hidden shadow-sm backdrop-blur-sm">
|
||||
<div
|
||||
class="absolute top-4 left-4 z-10 text-[10px] font-black uppercase text-on-surface-muted tracking-widest">
|
||||
{{ $t('common.network_stability') }}</div>
|
||||
<LatencyChart />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Expert Tip (Bottom) -->
|
||||
<div v-else
|
||||
class="mt-auto mx-auto max-w-lg w-full p-5 rounded-2xl bg-surface-panel/40 border border-border flex items-start gap-4 shadow-sm backdrop-blur-sm">
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center shrink-0 text-primary">
|
||||
<div class="i-material-symbols-lightbulb text-lg"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-on-surface mb-1">{{ $t('dashboard.did_you_know') }}</h4>
|
||||
<p class="text-xs text-on-surface-muted leading-relaxed">{{ $t('dashboard.hint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Sidebar: Configuration & Node Selector (Right) -->
|
||||
<aside
|
||||
class="w-80 lg:w-96 flex-none bg-surface-panel/60 border-l border-border/50 backdrop-blur-xl flex flex-col z-10 shadow-[-5px_0_30px_-5px_rgba(0,0,0,0.05)]">
|
||||
<!-- Sidebar Header -->
|
||||
<div class="p-5 border-b border-border/50 flex items-center justify-between">
|
||||
<span class="text-xs font-black uppercase tracking-widest text-on-surface-muted">{{
|
||||
$t('games.configuration') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 1. Node Selector (Takes most space) -->
|
||||
<div class="flex-1 overflow-hidden flex flex-col p-5 gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-bold text-on-surface">{{ $t('nodes.select_node') }}</span>
|
||||
<button @click="$router.push('/nodes')"
|
||||
class="text-xs font-medium text-primary hover:text-primary-hover hover:underline">{{
|
||||
$t('common.more') }}</button>
|
||||
</div>
|
||||
<!-- The Node Selector Component -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<NodeSelector :model-value="selectedNode" @update:modelValue="onNodeChange"
|
||||
:nodes="nodeStore.nodes" compact class="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. Proxy Mode (Bottom of Sidebar) -->
|
||||
<div class="p-5 border-t border-border/50 bg-surface/30">
|
||||
<label class="text-[10px] font-black uppercase tracking-widest text-on-surface-muted mb-3 block">
|
||||
{{ $t('games.mode_routing') }}
|
||||
</label>
|
||||
<div class="flex bg-surface-overlay p-1 rounded-xl border border-border">
|
||||
<button v-for="mode in ['process', 'routing']" :key="mode"
|
||||
@click="proxyMode = (mode as 'process' | 'routing')"
|
||||
class="flex-1 py-2 rounded-lg text-xs font-bold flex items-center justify-center gap-2 transition-all relative overflow-hidden"
|
||||
:class="proxyMode === mode ? 'bg-primary text-on-primary shadow-md' : 'text-on-surface-muted hover:text-on-surface'"
|
||||
:disabled="mode === 'process' && !canSelectProcessMode">
|
||||
|
||||
<div :class="mode === 'process' ? 'i-carbon-application-web' : 'i-carbon-network-overlay'">
|
||||
</div>
|
||||
{{ mode === 'process' ? $t('games.mode_process') : $t('games.mode_routing') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-on-surface-muted/60 mt-3 text-center">
|
||||
{{ proxyMode === 'process' ? $t('games.mode_process_desc') : $t('games.mode_routing_desc') }}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onUnmounted, onMounted } from 'vue'
|
||||
import { useGameStore, isRoutingOnlyPresetGame } from '@/stores/games'
|
||||
import { useCategoryStore } from '@/stores/categories'
|
||||
import { useNodeStore } from '@/stores/nodes'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import LatencyChart from '@/components/dashboard/LatencyChart.vue'
|
||||
import NodeSelector from '@/components/node/NodeSelector.vue'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
// Stores
|
||||
const gameStore = useGameStore()
|
||||
const categoryStore = useCategoryStore()
|
||||
const nodeStore = useNodeStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const { checkInterval } = storeToRefs(settingsStore)
|
||||
const message = useMessage()
|
||||
|
||||
// State
|
||||
const game = computed(() => gameStore.currentGame)
|
||||
const isRunning = computed(() => game.value?.status === 'accelerating')
|
||||
const currentLatency = ref(0)
|
||||
const currentLoss = ref(0)
|
||||
const startTime = ref(0)
|
||||
const durationSeconds = ref(0)
|
||||
const lastConnection = ref('')
|
||||
const isSwitchingNode = ref(false)
|
||||
const pendingNodeRestart = ref(false)
|
||||
|
||||
// Computed Helpers
|
||||
const categoryLabel = computed(() => {
|
||||
if (!game.value) return ''
|
||||
const cat = categoryStore.categories.find(c => c.id === game.value!.category)
|
||||
// @ts-ignore
|
||||
return cat?.name || i18n.global.t('common.uncategorized')
|
||||
})
|
||||
|
||||
const selectedNode = computed(() => game.value?.nodeId || null)
|
||||
|
||||
const proxyMode = computed({
|
||||
get: () => game.value?.proxyMode || 'process',
|
||||
set: (val) => {
|
||||
if (game.value && val) {
|
||||
if (val === 'process' && !canSelectProcessMode.value) return
|
||||
gameStore.updateGame({ ...game.value, proxyMode: val })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const canSelectProcessMode = computed(() => !isRoutingOnlyPresetGame(game.value))
|
||||
|
||||
const durationFormatted = computed(() => {
|
||||
const s = durationSeconds.value
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
const sec = s % 60
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
|
||||
})
|
||||
|
||||
async function restartForNodeSwitch() {
|
||||
if (!game.value || !isRunning.value) return
|
||||
|
||||
if (isSwitchingNode.value) {
|
||||
pendingNodeRestart.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isSwitchingNode.value = true
|
||||
try {
|
||||
do {
|
||||
pendingNodeRestart.value = false
|
||||
await gameStore.stopGame(game.value.id)
|
||||
await gameStore.startGame(game.value.id)
|
||||
startTime.value = Date.now()
|
||||
} while (pendingNodeRestart.value && !!game.value && isRunning.value)
|
||||
} catch (e: any) {
|
||||
message.error(String(e?.message || e || i18n.global.t('dashboard.node_switch_failed')))
|
||||
} finally {
|
||||
isSwitchingNode.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onNodeChange(val: string) {
|
||||
if (!game.value || !val) return
|
||||
const oldNodeId = game.value.nodeId
|
||||
if (oldNodeId === val) return
|
||||
|
||||
await gameStore.updateGame({ ...game.value, nodeId: val })
|
||||
await restartForNodeSwitch()
|
||||
}
|
||||
|
||||
// Lifecycle & Updates
|
||||
const { pause } = useIntervalFn(async () => {
|
||||
if (isRunning.value) {
|
||||
if (game.value?.nodeId) {
|
||||
const node = nodeStore.nodes.find(n => (n.id === game.value!.nodeId) || (n.tag === game.value!.nodeId))
|
||||
if (node) {
|
||||
const stats = await nodeStore.checkNode(node, isRunning.value ? 'tcp' : undefined)
|
||||
if (stats) {
|
||||
currentLatency.value = stats.latency
|
||||
currentLoss.value = stats.loss
|
||||
gameStore.updateLatency(game.value.id, currentLatency.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (startTime.value > 0) {
|
||||
durationSeconds.value = Math.floor((Date.now() - startTime.value) / 1000)
|
||||
}
|
||||
} else {
|
||||
currentLatency.value = 0
|
||||
currentLoss.value = 0
|
||||
durationSeconds.value = 0
|
||||
}
|
||||
}, checkInterval)
|
||||
|
||||
async function toggleAccelerator() {
|
||||
if (!game.value) return
|
||||
if (isSwitchingNode.value) return
|
||||
|
||||
if (isRunning.value) {
|
||||
try {
|
||||
await gameStore.stopGame(game.value.id)
|
||||
} finally {
|
||||
startTime.value = 0
|
||||
lastConnection.value = ''
|
||||
}
|
||||
} else {
|
||||
if (!selectedNode.value) {
|
||||
message.warning(i18n.global.t('dashboard.please_select_node'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
await gameStore.startGame(game.value.id)
|
||||
startTime.value = Date.now()
|
||||
} catch (e: any) {
|
||||
message.error(String(e?.message || e || i18n.global.t('dashboard.start_failed')))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLatencyTextColor(ms: number) {
|
||||
if (ms <= 0) return 'text-on-surface-muted'
|
||||
if (ms < 100) return 'text-success'
|
||||
if (ms < 200) return 'text-warning'
|
||||
return 'text-error'
|
||||
}
|
||||
|
||||
// Event Listeners
|
||||
function onSingboxEvent(name: string, data: any) {
|
||||
if (name === 'singbox-log' && data?.message) {
|
||||
const msg = data.message as string
|
||||
if (msg.includes('connection to')) {
|
||||
const parts = msg.split('connection to ')
|
||||
if (parts.length > 1) {
|
||||
const target = parts[1]?.split(' ')[0]?.trim()
|
||||
if (target) lastConnection.value = target
|
||||
}
|
||||
}
|
||||
} else if (name === 'singbox-error') {
|
||||
message.error(String(data || i18n.global.t('dashboard.singbox_error')))
|
||||
} else if (name === 'singbox-status' && data === 'stopped') {
|
||||
if (game.value && isRunning.value) {
|
||||
gameStore.setGameStatus(game.value.id, 'idle')
|
||||
startTime.value = 0
|
||||
lastConnection.value = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nodeStore.loadNodes()
|
||||
// @ts-ignore
|
||||
if (window.electron?.on) {
|
||||
window.electron.on('singbox-log', (d) => onSingboxEvent('singbox-log', d))
|
||||
window.electron.on('singbox-error', (d) => onSingboxEvent('singbox-error', d))
|
||||
window.electron.on('singbox-status', (d) => onSingboxEvent('singbox-status', d))
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
pause()
|
||||
// @ts-ignore
|
||||
if (window.electron?.off) {
|
||||
// Logic for cleanup if necessary
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
/* Animations */
|
||||
@keyframes pulse-slow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulse-slow 8s infinite ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin-slow 12s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in-up {
|
||||
animation: fade-in-up 0.5s ease-out;
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scale-in {
|
||||
animation: scale-in 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--rgb-border), 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(var(--rgb-border), 0.8);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="p-6 h-full flex flex-col">
|
||||
<!-- Header Controls -->
|
||||
<div class="flex flex-col gap-4 mb-6">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-on-surface">{{ $t('games.library') }}</h1>
|
||||
<div class="bg-surface rounded-lg p-1 flex border border-border">
|
||||
<button @click="viewMode = 'grid'" class="p-1.5 md:p-2 rounded transition"
|
||||
:class="viewMode === 'grid' ? 'bg-primary text-on-primary' : 'text-on-surface-muted hover:text-on-surface'">
|
||||
<div class="i-carbon-grid text-sm md:text-base"></div>
|
||||
</button>
|
||||
<button @click="viewMode = 'list'" class="p-1.5 md:p-2 rounded transition"
|
||||
:class="viewMode === 'list' ? 'bg-primary text-on-primary' : 'text-on-surface-muted hover:text-on-surface'">
|
||||
<div class="i-carbon-list text-sm md:text-base"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 md:gap-4 w-full sm:w-auto">
|
||||
<div class="relative flex-1 sm:flex-none">
|
||||
<input v-model="searchQuery" type="text" :placeholder="$t('games.search_placeholder')"
|
||||
class="bg-surface border border-border rounded-full py-1.5 pl-9 pr-4 text-xs md:text-sm text-on-surface w-full sm:w-48 lg:w-64 outline-none focus:border-primary placeholder:text-on-surface-muted" />
|
||||
<div class="i-carbon-search absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-muted text-sm"></div>
|
||||
</div>
|
||||
|
||||
<n-button round @click="scanGames" :loading="isScanning" class="shadow-sm">
|
||||
<template #icon>
|
||||
<div class="i-carbon-radar"></div>
|
||||
</template>
|
||||
<span class="hidden xs:inline">{{ isScanning ? $t('games.scanning') : $t('games.scan_local') }}</span>
|
||||
<span class="xs:hidden">{{ isScanning ? '...' : '扫描' }}</span>
|
||||
</n-button>
|
||||
|
||||
<n-button type="primary" round @click="openAddModal" class="shadow-lg shadow-primary/20">
|
||||
<template #icon>
|
||||
<div class="i-carbon-add"></div>
|
||||
</template>
|
||||
<span class="hidden xs:inline">{{ $t('games.add_game') }}</span>
|
||||
<span class="xs:hidden">添加</span>
|
||||
</n-button>
|
||||
|
||||
<n-button quaternary circle @click="showCategoryManager = true" :title="$t('common.manage_categories')">
|
||||
<template #icon>
|
||||
<div class="i-carbon-categories"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex gap-2 mb-6 overflow-x-auto pb-2">
|
||||
<button v-for="cat in categories" :key="cat.value" @click="activeCategory = cat.value"
|
||||
class="px-4 py-1 rounded-full text-sm border transition whitespace-nowrap"
|
||||
:class="activeCategory === cat.value ? 'bg-primary border-primary text-on-primary' : 'border-border text-on-surface-muted hover:border-border-hover hover:text-on-surface'">
|
||||
{{ cat.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Game Grid/List -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0 pr-2">
|
||||
<div v-if="filteredGames.length === 0"
|
||||
class="h-full flex flex-col items-center justify-center text-on-surface-muted">
|
||||
<div class="i-carbon-game-console text-4xl mb-2"></div>
|
||||
<p>{{ $t('games.no_games_found') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else
|
||||
:class="viewMode === 'grid' ? 'grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4' : 'flex flex-col gap-2'">
|
||||
<div v-for="game in filteredGames" :key="game.id" @click="selectGame(game.id)"
|
||||
class="bg-surface-panel border border-transparent hover:border-primary rounded-lg cursor-pointer transition group relative"
|
||||
:class="viewMode === 'grid' ? 'p-4 flex flex-col items-center text-center' : 'p-3 flex items-center gap-4'">
|
||||
<!-- Action Buttons (Hover) -->
|
||||
<div class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<n-button quaternary circle size="small" @click.stop="editGame(game)" :title="$t('common.edit')">
|
||||
<template #icon>
|
||||
<div class="i-carbon-edit text-xs"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
<n-button quaternary circle size="small" type="error" @click.stop="deleteGame(game)"
|
||||
:title="$t('common.delete')">
|
||||
<template #icon>
|
||||
<div class="i-carbon-trash-can text-xs"></div>
|
||||
</template>
|
||||
</n-button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-surface rounded-full flex items-center justify-center text-on-surface-muted group-hover:text-primary transition"
|
||||
:class="viewMode === 'grid' ? 'w-16 h-16 text-3xl mb-3' : 'w-10 h-10 text-xl'">
|
||||
<img v-if="game.iconUrl" :src="game.iconUrl" class="w-full h-full object-cover rounded-full" />
|
||||
<div v-else class="i-carbon-game-console"></div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-bold truncate text-on-surface" :class="viewMode === 'grid' ? 'text-base' : 'text-sm'">{{
|
||||
game.name }}</h3>
|
||||
<div class="text-xs text-on-surface-muted mt-1 flex items-center justify-center gap-2"
|
||||
:class="viewMode === 'list' && '!justify-start'">
|
||||
<span class="bg-surface px-2 rounded">{{ getCategoryLabel(game.category) }}</span>
|
||||
<span v-if="game.lastPlayed" class="text-on-surface-variant">{{ formatTime(game.lastPlayed) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="game.id === gameStore.currentGameId"
|
||||
class="text-primary text-xs font-bold flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
||||
{{ $t('games.selected') }}
|
||||
</div>
|
||||
<div v-else-if="runningGames.includes(game.id)"
|
||||
class="text-success text-xs font-bold flex items-center gap-1">
|
||||
<div class="w-2 h-2 rounded-full bg-success animate-pulse"></div>
|
||||
{{ $t('games.running') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdvancedConfigEditor v-model="showEditModal" :editing-game="editingGame" @save="handleSaveGame" />
|
||||
|
||||
<CategoryManager v-model="showCategoryManager" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGameStore, type Game } from '@/stores/games'
|
||||
import { useCategoryStore } from '@/stores/categories'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
import { useMessage, useDialog } from 'naive-ui'
|
||||
import AdvancedConfigEditor from '@/components/library/AdvancedConfigEditor.vue'
|
||||
import CategoryManager from '@/components/library/CategoryManager.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
const router = useRouter()
|
||||
const gameStore = useGameStore()
|
||||
const categoryStore = useCategoryStore()
|
||||
|
||||
const viewMode = ref<'grid' | 'list'>('grid')
|
||||
const searchQuery = ref('')
|
||||
const activeCategory = ref('all')
|
||||
const isScanning = ref(false)
|
||||
const runningGames = computed(() => gameStore.runningGames)
|
||||
|
||||
const showEditModal = ref(false)
|
||||
const showCategoryManager = ref(false)
|
||||
const editingGame = ref<Game | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
categoryStore.loadCategories()
|
||||
})
|
||||
|
||||
const categories = computed(() => {
|
||||
const list = categoryStore.categories.map(c => ({
|
||||
label: c.name,
|
||||
value: c.id
|
||||
}))
|
||||
return [{ label: '全部', value: 'all' }, ...list]
|
||||
})
|
||||
|
||||
function getCategoryLabel(id: string) {
|
||||
const cat = categoryStore.categories.find(c => c.id === id)
|
||||
return cat?.name || '未分类'
|
||||
}
|
||||
|
||||
const filteredGames = computed(() => {
|
||||
return gameStore.gameLibrary.filter(game => {
|
||||
const matchSearch = game.name.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
const matchCat = activeCategory.value === 'all' || game.category === activeCategory.value
|
||||
return matchSearch && matchCat
|
||||
}).sort((a, b) => {
|
||||
const aRunning = runningGames.value.includes(a.id)
|
||||
const bRunning = runningGames.value.includes(b.id)
|
||||
if (aRunning !== bRunning) return aRunning ? -1 : 1
|
||||
|
||||
const aTime = a.lastPlayed || 0
|
||||
const bTime = b.lastPlayed || 0
|
||||
return bTime - aTime
|
||||
})
|
||||
})
|
||||
|
||||
async function scanGames() {
|
||||
isScanning.value = true
|
||||
try {
|
||||
const processes = await window.system.scanProcesses()
|
||||
const matched = gameStore.matchRunningGames(processes)
|
||||
if (matched.length > 0) {
|
||||
if (!gameStore.currentGame?.status || gameStore.currentGame.status === 'idle') {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => { isScanning.value = false }, 500)
|
||||
}
|
||||
}
|
||||
|
||||
function selectGame(id: string) {
|
||||
gameStore.setCurrentGame(id)
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
return useTimeAgo(timestamp).value
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
editingGame.value = null
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
function editGame(game: Game) {
|
||||
editingGame.value = game
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
async function deleteGame(game: Game) {
|
||||
dialog.warning({
|
||||
title: t('common.delete') || '确认删除',
|
||||
content: t('games.delete_confirm') || '您确定要删除这个游戏吗?',
|
||||
positiveText: t('common.delete') || '删除',
|
||||
negativeText: t('common.cancel') || '取消',
|
||||
onPositiveClick: async () => {
|
||||
await gameStore.removeGame(game.id)
|
||||
message.success(t('common.deleted') || '已删除')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleSaveGame(gameData: Game | Omit<Game, 'id'>) {
|
||||
if ('id' in gameData) {
|
||||
await gameStore.updateGame(gameData as Game)
|
||||
} else {
|
||||
const newGame = {
|
||||
...gameData,
|
||||
id: Date.now().toString(36),
|
||||
latency: 0,
|
||||
status: 'idle'
|
||||
} as Game
|
||||
await gameStore.addGame(newGame)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="p-6 h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4 mb-6">
|
||||
<h1 class="text-xl md:text-2xl font-bold text-on-surface">{{ $t('common.nodes') }}</h1>
|
||||
<div class="flex flex-wrap items-center gap-2 w-full sm:w-auto">
|
||||
<button @click="checkAllNodes" :disabled="isChecking"
|
||||
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 md:px-4 py-1.5 bg-surface border border-border hover:bg-surface-overlay rounded-full text-xs md:text-sm text-on-surface transition">
|
||||
<div class="i-material-symbols-network-check" :class="{ 'animate-pulse': isChecking }"></div>
|
||||
<span class="hidden xs:inline">{{ isChecking ? $t('common.checking') : $t('nodes.check_latency') }}</span>
|
||||
<span class="xs:hidden">{{ isChecking ? '...' : $t('nodes.check_latency') }}</span>
|
||||
</button>
|
||||
<button @click="showImportModal = true"
|
||||
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 md:px-4 py-1.5 bg-surface border border-border hover:bg-surface-overlay rounded-full text-xs md:text-sm text-on-surface transition">
|
||||
<div class="i-material-symbols-download"></div>
|
||||
<span class="hidden xs:inline">{{ $t('common.import') }}</span>
|
||||
<span class="xs:hidden">{{ $t('common.import') }}</span>
|
||||
</button>
|
||||
<button @click="exportNodes"
|
||||
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 md:px-4 py-1.5 bg-surface border border-border hover:bg-surface-overlay rounded-full text-xs md:text-sm text-on-surface transition">
|
||||
<div class="i-material-symbols-upload"></div>
|
||||
<span class="hidden xs:inline">{{ $t('common.export') }}</span>
|
||||
<span class="xs:hidden">{{ $t('common.export') }}</span>
|
||||
</button>
|
||||
<button @click="openAddModal"
|
||||
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-3 md:px-4 py-1.5 bg-primary text-on-primary hover:bg-primary-hover rounded-full text-xs md:text-sm transition shadow-lg shadow-primary/20">
|
||||
<div class="i-material-symbols-add"></div>
|
||||
<span class="hidden xs:inline">{{ $t('common.add') }}</span>
|
||||
<span class="xs:hidden">{{ $t('common.add') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Node Manager (List Only) -->
|
||||
<div class="flex-1 bg-surface-panel rounded-xl border border-border overflow-hidden flex flex-col">
|
||||
<NodeManager />
|
||||
</div>
|
||||
|
||||
<NodeEditModal v-model="showEditModal" :editing-node="editingNode" @save="handleSaveNode" />
|
||||
|
||||
<NodeImportModal v-model="showImportModal" @imported="handleImported" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useMessage } from 'naive-ui'
|
||||
import NodeManager from '@/components/node/NodeManager.vue'
|
||||
import NodeEditModal from '@/components/node/NodeEditModal.vue'
|
||||
import NodeImportModal from '@/components/node/NodeImportModal.vue'
|
||||
import type { NodeConfig } from '@/utils/protocol'
|
||||
import { useNodeStore } from '@/stores/nodes'
|
||||
import { generateBatchLinks } from '@/utils/protocol'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import i18n from '@/i18n'
|
||||
|
||||
const nodeStore = useNodeStore()
|
||||
const message = useMessage()
|
||||
const { copy } = useClipboard()
|
||||
const showEditModal = ref(false)
|
||||
const showImportModal = ref(false)
|
||||
const editingNode = ref<NodeConfig | null>(null)
|
||||
const isChecking = ref(false)
|
||||
|
||||
function openAddModal() {
|
||||
editingNode.value = null
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
async function handleSaveNode(node: Omit<NodeConfig, 'id'> | NodeConfig) {
|
||||
const ok = await nodeStore.saveNode(node as NodeConfig)
|
||||
if (!ok) {
|
||||
message.error(i18n.global.t('nodes.save_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
function handleImported() {
|
||||
nodeStore.loadNodes()
|
||||
}
|
||||
|
||||
async function checkAllNodes() {
|
||||
if (isChecking.value) return
|
||||
isChecking.value = true
|
||||
try {
|
||||
await nodeStore.checkAllNodes()
|
||||
} finally {
|
||||
isChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function exportNodes() {
|
||||
if (nodeStore.nodes.length === 0) {
|
||||
message.warning(i18n.global.t('nodes.no_nodes_to_export'))
|
||||
return
|
||||
}
|
||||
const content = generateBatchLinks(nodeStore.nodes)
|
||||
if (content) {
|
||||
await copy(content)
|
||||
message.success(i18n.global.t('nodes.export_success', { count: nodeStore.nodes.length }))
|
||||
} else {
|
||||
message.error(i18n.global.t('nodes.export_failed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<h1 class="text-2xl font-bold mb-4 text-on-surface">{{ $t('common.settings') }}</h1>
|
||||
|
||||
<div class="bg-surface-panel p-6 rounded-lg space-y-6 border border-border">
|
||||
<!-- Language Settings -->
|
||||
<div>
|
||||
<h2 class="text-lg mb-4 text-on-surface-variant">{{ $t('settings.language') }}</h2>
|
||||
<div class="flex gap-4">
|
||||
<button v-for="locale in availableLocales" :key="locale.code" @click="setLanguage(locale.code)"
|
||||
class="px-4 py-2 rounded transition border" :class="currentLocale === locale.code
|
||||
? 'bg-primary border-primary text-on-primary'
|
||||
: 'bg-surface border-border text-on-surface-muted hover:border-border-hover hover:text-on-surface'">
|
||||
{{ locale.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theme Settings -->
|
||||
<div>
|
||||
<h2 class="text-lg mb-4 text-on-surface-variant">{{ $t('settings.theme_color') }}</h2>
|
||||
<div class="flex gap-4">
|
||||
<button v-for="color in themeColors" :key="color.value" @click="setThemeColor(color.value)"
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center transition border-2" :class="[
|
||||
color.bg,
|
||||
themeColor === color.value ? 'border-on-surface scale-110' : 'border-transparent hover:scale-105'
|
||||
]" :title="color.label">
|
||||
<div v-if="themeColor === color.value" class="i-material-symbols-check text-white font-bold"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Network Settings -->
|
||||
<div>
|
||||
<h2 class="text-lg mb-4 text-on-surface-variant">{{ $t('settings.network') }}</h2>
|
||||
<div class="space-y-4 max-w-md">
|
||||
<!-- Check Method -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-on-surface">{{ $t('settings.check_method') }}</span>
|
||||
<div class="flex bg-surface-overlay rounded-lg p-1">
|
||||
<button v-for="method in ['ping', 'tcp']" :key="method" @click="setCheckMethod(method)"
|
||||
class="px-3 py-1 text-sm rounded transition" :class="settingsStore.checkMethod === method
|
||||
? 'bg-surface shadow text-on-surface'
|
||||
: 'text-on-surface-muted hover:text-on-surface'">
|
||||
{{ method.toUpperCase() }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interval -->
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-on-surface">{{ $t('settings.check_interval') }}</span>
|
||||
<select v-model.number="settingsStore.checkInterval"
|
||||
class="bg-surface border border-border rounded px-3 py-1.5 text-sm text-on-surface focus:outline-none focus:border-primary">
|
||||
<option :value="1000">1s</option>
|
||||
<option :value="2000">2s</option>
|
||||
<option :value="5000">5s</option>
|
||||
<option :value="10000">10s</option>
|
||||
<option :value="30000">30s</option>
|
||||
<option :value="60000">1m</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-border">
|
||||
<p class="text-on-surface-muted">{{ $t('settings.coming_soon') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useTheme, type ThemeColor } from '@/composables/useTheme'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
|
||||
const { locale } = useI18n()
|
||||
const { themeColor, setThemeColor } = useTheme()
|
||||
const settingsStore = useSettingsStore()
|
||||
|
||||
const currentLocale = computed(() => locale.value)
|
||||
|
||||
const availableLocales = [
|
||||
{ code: 'zh-CN', name: '简体中文' },
|
||||
{ code: 'en-US', name: 'English' }
|
||||
]
|
||||
|
||||
const themeColors: { value: ThemeColor, label: string, bg: string }[] = [
|
||||
{ value: 'green', label: 'Green', bg: 'bg-[#3eaf7c]' },
|
||||
{ value: 'blue', label: 'Blue', bg: 'bg-[#3b82f6]' },
|
||||
{ value: 'purple', label: 'Purple', bg: 'bg-[#8b5cf6]' },
|
||||
{ value: 'orange', label: 'Orange', bg: 'bg-[#f97316]' },
|
||||
{ value: 'red', label: 'Red', bg: 'bg-[#ef4444]' }
|
||||
]
|
||||
|
||||
function setCheckMethod(method: string) {
|
||||
// @ts-ignore
|
||||
settingsStore.checkMethod = method
|
||||
}
|
||||
|
||||
function setLanguage(lang: string) {
|
||||
locale.value = lang
|
||||
// In a real app, you might want to save this to localStorage or electron-store
|
||||
}
|
||||
</script>
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface Window {
|
||||
system: {
|
||||
scanProcesses: () => Promise<string[]>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// 自动补全用
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { CategoryManager, type Category } from '../../electron/categories/manager'
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
describe('CategoryManager', () => {
|
||||
let manager: CategoryManager
|
||||
let categories: Category[]
|
||||
let db: any
|
||||
|
||||
beforeEach(async () => {
|
||||
categories = [
|
||||
{ id: 'fps', name: 'FPS', order: 1 },
|
||||
{ id: 'moba', name: 'MOBA', order: 2 }
|
||||
]
|
||||
|
||||
db = {
|
||||
getAllCategories: vi.fn(async () => [...categories]),
|
||||
saveCategory: vi.fn(async (category: Category) => {
|
||||
const index = categories.findIndex(c => c.id === category.id)
|
||||
if (index >= 0) {
|
||||
categories[index] = { ...categories[index], ...category }
|
||||
} else {
|
||||
categories.push(category)
|
||||
}
|
||||
return [...categories]
|
||||
}),
|
||||
deleteCategory: vi.fn(async (id: string) => {
|
||||
categories = categories.filter(c => c.id !== id)
|
||||
return [...categories]
|
||||
})
|
||||
}
|
||||
|
||||
manager = new CategoryManager(db)
|
||||
await manager.getAll()
|
||||
})
|
||||
|
||||
it('初始化后可以读取分类', async () => {
|
||||
const list = await manager.getAll()
|
||||
expect(list.length).toBeGreaterThan(0)
|
||||
expect(list.find(c => c.id === 'fps')).toBeDefined()
|
||||
})
|
||||
|
||||
it('可以新增分类', async () => {
|
||||
const newCat: Category = { id: 'test', name: 'Test Category' }
|
||||
await manager.save(newCat)
|
||||
|
||||
const list = await manager.getAll()
|
||||
expect(list.find(c => c.id === 'test')).toEqual(expect.objectContaining(newCat))
|
||||
})
|
||||
|
||||
it('可以更新分类', async () => {
|
||||
await manager.save({ id: 'fps', name: 'FPS Games' })
|
||||
|
||||
const list = await manager.getAll()
|
||||
expect(list.find(c => c.id === 'fps')?.name).toBe('FPS Games')
|
||||
})
|
||||
|
||||
it('可以删除分类', async () => {
|
||||
await manager.delete('fps')
|
||||
|
||||
const list = await manager.getAll()
|
||||
expect(list.find(c => c.id === 'fps')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { JsonStore } from '../../electron/config/store';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
describe('JsonStore', () => {
|
||||
let tempDir: string;
|
||||
let store: JsonStore<any>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = path.join(os.tmpdir(), 'lagzero-test-' + Date.now());
|
||||
await fs.ensureDir(tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.remove(tempDir);
|
||||
});
|
||||
|
||||
it('should initialize with default values', async () => {
|
||||
store = new JsonStore({
|
||||
name: 'test-config',
|
||||
cwd: tempDir,
|
||||
defaults: { foo: 'bar' }
|
||||
});
|
||||
|
||||
// Wait for async file operations
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const content = await fs.readJson(path.join(tempDir, 'test-config.json'));
|
||||
expect(content).toEqual({ foo: 'bar' });
|
||||
});
|
||||
|
||||
it('should save and load data', async () => {
|
||||
store = new JsonStore({
|
||||
name: 'test-config',
|
||||
cwd: tempDir,
|
||||
defaults: { foo: 'bar' }
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
store.set({ foo: 'baz' });
|
||||
|
||||
// Wait for async save
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(store.get()).toEqual({ foo: 'baz' });
|
||||
|
||||
const content = await fs.readJson(path.join(tempDir, 'test-config.json'));
|
||||
expect(content.foo).toBe('baz');
|
||||
});
|
||||
|
||||
it('should create backups on save', async () => {
|
||||
store = new JsonStore({
|
||||
name: 'test-backup',
|
||||
cwd: tempDir,
|
||||
defaults: { count: 0 },
|
||||
backupCount: 3
|
||||
});
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
store.set({ count: 1 });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
store.set({ count: 2 });
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const backupDir = path.join(tempDir, 'backups', 'test-backup');
|
||||
expect(await fs.pathExists(backupDir)).toBe(true);
|
||||
|
||||
const files = await fs.readdir(backupDir);
|
||||
// At least 1 backup should exist (from first save or subsequent saves)
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateSingboxConfig } from '../../src/utils/singbox-config'
|
||||
import type { Game } from '../../src/types'
|
||||
import type { NodeConfig } from '../../src/utils/protocol'
|
||||
|
||||
describe('generateSingboxConfig', () => {
|
||||
const mockGame: Game = {
|
||||
id: 'game-1',
|
||||
name: 'Test Game',
|
||||
processName: ['game.exe'],
|
||||
category: 'fps',
|
||||
nodeId: 'node-1',
|
||||
proxyMode: 'process'
|
||||
}
|
||||
|
||||
const mockNode: NodeConfig = {
|
||||
id: 'node-1',
|
||||
type: 'vmess',
|
||||
tag: 'Test Node',
|
||||
server: 'example.com',
|
||||
server_port: 443,
|
||||
uuid: 'uuid-123',
|
||||
security: 'auto',
|
||||
network: 'ws',
|
||||
path: '/ws',
|
||||
tls: {
|
||||
enabled: true,
|
||||
server_name: 'example.com'
|
||||
}
|
||||
}
|
||||
|
||||
it('should generate valid JSON config', () => {
|
||||
const configStr = generateSingboxConfig(mockGame, mockNode)
|
||||
expect(() => JSON.parse(configStr)).not.toThrow()
|
||||
const config = JSON.parse(configStr)
|
||||
expect(config.log.level).toBe('info')
|
||||
expect(config.inbounds[0].type).toBe('tun')
|
||||
})
|
||||
|
||||
it('should configure process mode correctly', () => {
|
||||
const config = JSON.parse(generateSingboxConfig(mockGame, mockNode))
|
||||
// Process mode: default final should be 'direct' (implied by not being 'proxy' or explicit final)
|
||||
// Actually in my impl: final: isRoutingMode ? 'proxy' : 'direct'
|
||||
expect(config.route.final).toBe('direct')
|
||||
|
||||
// Should have rule for game process -> proxy
|
||||
const gameRule = config.route.rules.find((r: any) => r.process_name && r.process_name.includes('game.exe'))
|
||||
expect(gameRule).toBeDefined()
|
||||
expect(gameRule.outbound).toBe('proxy')
|
||||
})
|
||||
|
||||
it('should configure routing mode correctly', () => {
|
||||
const routingGame = { ...mockGame, proxyMode: 'routing' as const }
|
||||
const config = JSON.parse(generateSingboxConfig(routingGame, mockNode))
|
||||
|
||||
expect(config.route.final).toBe('proxy')
|
||||
|
||||
expect(config.dns.final).toBe('google')
|
||||
|
||||
const tunInbound = config.inbounds.find((i: any) => i.tag === 'tun-in')
|
||||
expect(tunInbound).toBeDefined()
|
||||
expect(tunInbound.inet4_address).toBeDefined()
|
||||
})
|
||||
|
||||
it('should include node outbound', () => {
|
||||
const config = JSON.parse(generateSingboxConfig(mockGame, mockNode))
|
||||
const nodeOut = config.outbounds.find((o: any) => o.tag === 'node-out')
|
||||
expect(nodeOut).toBeDefined()
|
||||
expect(nodeOut.type).toBe('vmess')
|
||||
expect(nodeOut.server).toBe('example.com')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { defineConfig, presetUno, presetAttributify, presetIcons, transformerDirectives } from 'unocss'
|
||||
|
||||
export default defineConfig({
|
||||
presets: [
|
||||
presetUno(),
|
||||
presetAttributify(),
|
||||
presetIcons({
|
||||
scale: 1.2,
|
||||
warn: true,
|
||||
}),
|
||||
],
|
||||
transformers: [
|
||||
transformerDirectives(),
|
||||
],
|
||||
theme: {
|
||||
breakpoints: {
|
||||
xs: '480px',
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
},
|
||||
colors: {
|
||||
primary: 'rgba(var(--rgb-primary), <alpha-value>)',
|
||||
'primary-hover': 'rgba(var(--rgb-primary-hover), <alpha-value>)',
|
||||
'on-primary': 'rgba(var(--rgb-on-primary), <alpha-value>)',
|
||||
|
||||
secondary: 'rgba(var(--rgb-secondary), <alpha-value>)',
|
||||
'secondary-hover': 'rgba(var(--rgb-secondary-hover), <alpha-value>)',
|
||||
'on-secondary': 'rgba(var(--rgb-on-secondary), <alpha-value>)',
|
||||
|
||||
success: 'rgba(var(--rgb-success), <alpha-value>)',
|
||||
'success-bg': 'rgba(var(--rgb-success-bg), <alpha-value>)',
|
||||
'on-success': 'rgba(var(--rgb-on-success), <alpha-value>)',
|
||||
|
||||
warning: 'rgba(var(--rgb-warning), <alpha-value>)',
|
||||
'warning-bg': 'rgba(var(--rgb-warning-bg), <alpha-value>)',
|
||||
'on-warning': 'rgba(var(--rgb-on-warning), <alpha-value>)',
|
||||
|
||||
error: 'rgba(var(--rgb-error), <alpha-value>)',
|
||||
'error-bg': 'rgba(var(--rgb-error-bg), <alpha-value>)',
|
||||
'on-error': 'rgba(var(--rgb-on-error), <alpha-value>)',
|
||||
|
||||
info: 'rgba(var(--rgb-info), <alpha-value>)',
|
||||
'info-bg': 'rgba(var(--rgb-info-bg), <alpha-value>)',
|
||||
'on-info': 'rgba(var(--rgb-on-info), <alpha-value>)',
|
||||
|
||||
// Semantic Surface Colors
|
||||
background: 'rgba(var(--rgb-background), <alpha-value>)',
|
||||
surface: 'rgba(var(--rgb-surface), <alpha-value>)',
|
||||
'surface-panel': 'rgba(var(--rgb-surface-panel), <alpha-value>)',
|
||||
'surface-overlay': 'rgba(var(--rgb-surface-overlay), <alpha-value>)',
|
||||
|
||||
// Semantic Text Colors
|
||||
'on-surface': 'rgba(var(--rgb-on-surface), <alpha-value>)',
|
||||
'on-surface-variant': 'rgba(var(--rgb-on-surface-variant), <alpha-value>)',
|
||||
'on-surface-muted': 'rgba(var(--rgb-on-surface-muted), <alpha-value>)',
|
||||
|
||||
// Borders
|
||||
border: 'rgba(var(--rgb-border), <alpha-value>)',
|
||||
'border-hover': 'rgba(var(--rgb-border-hover), <alpha-value>)',
|
||||
|
||||
// Overlay
|
||||
overlay: 'rgba(var(--rgb-overlay), <alpha-value>)',
|
||||
|
||||
// Legacy support (to be refactored)
|
||||
dark: {
|
||||
bg: 'rgba(var(--rgb-surface), <alpha-value>)',
|
||||
panel: 'rgba(var(--rgb-surface-panel), <alpha-value>)',
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import electron from 'vite-plugin-electron/simple'
|
||||
import UnoCSS from 'unocss/vite'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
|
||||
import { resolve } from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
UnoCSS(),
|
||||
AutoImport({
|
||||
imports: [
|
||||
'vue',
|
||||
{
|
||||
'naive-ui': [
|
||||
'useDialog',
|
||||
'useMessage',
|
||||
'useNotification',
|
||||
'useLoadingBar'
|
||||
]
|
||||
}
|
||||
],
|
||||
dts: 'src/auto-imports.d.ts'
|
||||
}),
|
||||
Components({
|
||||
resolvers: [NaiveUiResolver()],
|
||||
dts: 'src/components.d.ts'
|
||||
}),
|
||||
electron({
|
||||
main: {
|
||||
entry: 'electron/main.ts',
|
||||
vite: {
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['better-sqlite3', 'kysely'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
preload: {
|
||||
input: 'electron/preload.ts',
|
||||
},
|
||||
renderer: {},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
},
|
||||
include: ['src/**/*.test.ts', 'tests/**/*.spec.ts']
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user