first commit
2
.cargo/config.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||||
|
rustflags = ["-C", "target-feature=+crt-static"]
|
||||||
5
.env
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
GRPC_ENDPOINT="156.229.167.121:9090"
|
||||||
|
COSMOS_ENDPOINT="http://156.229.167.121:26657"
|
||||||
|
NODE_SECRET="aHVnZSBjb21wYW55IHBob25lIHdlc3QgcGxhY2Ugc2VtaW5hciBtaXJhY2xlIGxlbmQgbWFuZGF0ZSB0aGVuIGFkanVzdCBxdWl0IG1lYXQgY2hlYXAgbm9vZGxlIGNvdXBsZSBkZWZpbmUgbXVzY2xlIHB1bHNlIHNpc3RlciBwaWVjZSBkZXZpY2UgcHJpdmF0ZSBob29k"
|
||||||
|
IS_DEBUG="true"
|
||||||
|
ACCOUNT_NAME="de1"
|
||||||
24
.gitignore
vendored
Normal file
@ -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
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||||
|
}
|
||||||
102
README.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# Paw GUI
|
||||||
|
|
||||||
|
Paw 的 GUI 客户端,基于 Tauri 构建。
|
||||||
|
|
||||||
|
- 有关系统服务部分,请看其[独立的文档](./src-tauri/system
|
||||||
|
-service/README.md)
|
||||||
|
- 有关 Rust 部分,请看其[独立的文档](./src-tauri/README.md),下文是前端的项目结构介绍
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
- index.html 前端入口
|
||||||
|
- package.json 包管理器配置文件
|
||||||
|
- tailwind.config.js tailwindcss配置文件
|
||||||
|
- postcss.config.js postcss配置文件
|
||||||
|
- tsconfig.json ts编译器配置
|
||||||
|
- tsconfig.node.json 同上
|
||||||
|
- vite.config.ts vite配置,管理打包相关
|
||||||
|
- **`build-sidecar.mjs` 用于打包 sidecar 程序,在调试、发布前会被调用**
|
||||||
|
- src/
|
||||||
|
- assets/ 前端使用的静态资源,如图片、字体
|
||||||
|
- components/ 前端组件
|
||||||
|
- pages/ 前端不同页面
|
||||||
|
- utils/ 实用工具模块
|
||||||
|
- App.css 全局 CSS 代码块,目前仅用于导入 tailwindcss
|
||||||
|
- App.tsx 软件页面入口
|
||||||
|
- **bindings.ts 自动生成的 command、event 和类型的定义(从 rust 端)**
|
||||||
|
|
||||||
|
## 如何启动/调试项目
|
||||||
|
|
||||||
|
1. 安装语言工具链(需要node、pnpm、rust工具链和go工具链),目前需要go工具链是因为 `build-sidecar.mjs` 中也生成 CLI 的二进制文件,未来可能改为下载
|
||||||
|
2. 安装依赖 `pnpm i`
|
||||||
|
3. 启动调试模式 `pnpm tauri dev` (`build-sidecar.mjs`会在其中自动先运行,但是如果更改了 sidecar 的源代码,需要手动重新运行该命令,sidecar 包括 `src-tauri/system-service` 和 `../proxy`)
|
||||||
|
|
||||||
|
## 如何打包测试客户端
|
||||||
|
|
||||||
|
假设你已经完成了调试的步骤,那么只需要执行 `pnpm tauri build` 即可构建项目,构建完成后会在 `src-tauri/target/release/bundle` 中生成对应的**测试用安装包**。
|
||||||
|
|
||||||
|
## 如何打包正式客户端
|
||||||
|
|
||||||
|
首先,需要按照下表设置环境变量
|
||||||
|
|
||||||
|
|变量名|值类型|示例值|说明|
|
||||||
|
|---|---|---|---|
|
||||||
|
|GRPC_ENDPOINT|用逗号分割的端点组|"156.229.167.121:9090,"|GRPC 服务的端点|
|
||||||
|
|COSMOS_ENDPOINT|用逗号分割的端点组|"http://156.229.167.121:26657,"|COSMOS 服务的端点|
|
||||||
|
|NODE_SECRET|字符串|"ZXN0YXRlIH…xhgdmlkZW8="|账户助记词的base64|
|
||||||
|
|IS_DEBUG|字符串|"false"|必须为"true"或"false"|
|
||||||
|
|ACCOUNT_NAME|字符串|"de1"|账户名|
|
||||||
|
|
||||||
|
然后,执行 `pnpm tauri build` 即可构建项目,构建完成后会在 `src-tauri/target/release/bundle` 中生成对应的**正式用安装包**。
|
||||||
|
|
||||||
|
注意,**启动后程序会将配置文件持久化到 `%AppData%\com.paw.paw-gui` 中**,这个路径由 tauri 的 store 插件指定,如果使用了不同的环境变量构建程序,并且想要重复启动调试,还需要删除这个里面的json文件
|
||||||
|
|
||||||
|
这个路径在不同操作系统是不同的,查看Tauri的源代码发现其利用了库 dirs 的 data_dir,如下:
|
||||||
|
|
||||||
|
- Linux: `XDG_DATA_HOME/com.paw.paw-gui` 或 `$HOME/.local/share/com.paw.paw-gui`
|
||||||
|
- Windows: `%AppData%/com.paw.paw-gui`
|
||||||
|
- macOS: `$HOME/Library/Application Support/com.paw.paw-gui`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## 多个二进制之间的关系
|
||||||
|
|
||||||
|
- GUI: 本项目,直接调用安装卸载器,通过HTTP与Core、Service通信
|
||||||
|
- Core: Go编写的命令行程序,位于 `../proxy/cmd/client`
|
||||||
|
- SystemService: 运行在系统服务中的二进制,位于 `src-tauri/system-service`
|
||||||
|
- ServiceInstaller: 用于安装系统服务的二进制,位于 `src-tauri/system-service/installer/install.rs`
|
||||||
|
- ServiceUninstaller: 用于卸载系统服务的二进制,位于 `src-tauri/system-service/installer/uninstall.rs`
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Core <-- HTTP --> GUI
|
||||||
|
|
||||||
|
|
||||||
|
SystemService <-- HTTP --> GUI
|
||||||
|
|
||||||
|
GUI -- sudo exec --> ServiceInstaller
|
||||||
|
ServiceInstaller -- install --> SystemService
|
||||||
|
GUI -- sudo exec --> ServiceUninstaller
|
||||||
|
ServiceUninstaller -- uninstall --> SystemService
|
||||||
|
|
||||||
|
SystemService -- exec/kill --> Core
|
||||||
|
```
|
||||||
|
|
||||||
|
## 如何更改 sidecar 的名称?
|
||||||
|
|
||||||
|
1. 更改对应 `Cargo.toml` 中 `bin/name` 的名字(系统服务这么改,CLI直接修改GO项目输出文件名即可)
|
||||||
|
2. 更改 `build-sidecar.mjs` 脚本中 `binaries` 数组中的名称字符串与上一步匹配(CLI则修改下载文件名)
|
||||||
|
3. 更改 `tauri.config.json` 中 `externalBin` 名称
|
||||||
|
4. 更改 `src-tauri/common/lib.rs` 中的静态字符串名称
|
||||||
|
5. 更改 `src-tauri/install-scripts` 中安装脚本中的字符串名称
|
||||||
|
|
||||||
|
## 如何更新程序版本号?
|
||||||
|
|
||||||
|
更改 `src-tauri/Cargo.toml` 中的 `workspace.package.version`(GUI、系统服务和 Common 的 package 版本号),不需要管理 `package.json` 版本号。不论是 cargo 识别到的环境变量,还是 tauri 中获取的版本号,都是以此为准。
|
||||||
|
|
||||||
|
若 GUI 发现系统服务返回的版本号与自身不一致,会执行卸载重装系统服务的操作,使得系统服务版本再次与自己匹配。
|
||||||
|
|
||||||
|
## 其他注意事项
|
||||||
|
|
||||||
|
- 在 Linux 上,提权依赖 pkexec,因此在 WSL 下,通过程序内安装服务需要先安装 polkit,而且需要有桌面环境(但是在程序通过包管理器安装时应该已经安装过服务了,这个仅作为修复服务的方案)
|
||||||
BIN
app-icon.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
29
auto-imports.d.ts
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
// Generated by unplugin-auto-import
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
declare global {
|
||||||
|
const createRef: typeof import('react')['createRef']
|
||||||
|
const forwardRef: typeof import('react')['forwardRef']
|
||||||
|
const lazy: typeof import('react')['lazy']
|
||||||
|
const memo: typeof import('react')['memo']
|
||||||
|
const startTransition: typeof import('react')['startTransition']
|
||||||
|
const useCallback: typeof import('react')['useCallback']
|
||||||
|
const useContext: typeof import('react')['useContext']
|
||||||
|
const useDebugValue: typeof import('react')['useDebugValue']
|
||||||
|
const useDeferredValue: typeof import('react')['useDeferredValue']
|
||||||
|
const useEffect: typeof import('react')['useEffect']
|
||||||
|
const useId: typeof import('react')['useId']
|
||||||
|
const useImperativeHandle: typeof import('react')['useImperativeHandle']
|
||||||
|
const useInsertionEffect: typeof import('react')['useInsertionEffect']
|
||||||
|
const useLayoutEffect: typeof import('react')['useLayoutEffect']
|
||||||
|
const useMemo: typeof import('react')['useMemo']
|
||||||
|
const useReducer: typeof import('react')['useReducer']
|
||||||
|
const useRef: typeof import('react')['useRef']
|
||||||
|
const useState: typeof import('react')['useState']
|
||||||
|
const useSyncExternalStore: typeof import('react')['useSyncExternalStore']
|
||||||
|
const useTransition: typeof import('react')['useTransition']
|
||||||
|
}
|
||||||
21
components.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/App.css",
|
||||||
|
"baseColor": "zinc",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
14
index.html
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Tauri + React + Typescript</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
justfile
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
set dotenv-required
|
||||||
|
set dotenv-path := ".env"
|
||||||
|
|
||||||
|
# 显示所有指令
|
||||||
|
default:
|
||||||
|
@just --list
|
||||||
|
|
||||||
|
# 构建系统服务、系统服务安装器、系统服务卸载器,存放到 src-tauri/sidecar 目录下
|
||||||
|
build-system-service:
|
||||||
|
for name in paw-system-service paw-install-service paw-uninstall-service; do \
|
||||||
|
echo "Building $name with manifest {{system_service_manifest}}" \
|
||||||
|
&& cargo build --release --bin $name --manifest-path "{{system_service_manifest}}" \
|
||||||
|
&& cp "{{cargo_target_path}}/$name" "src-tauri/sidecar/${name}-{{target_triple}}{{bin_ext}}"; \
|
||||||
|
done
|
||||||
|
|
||||||
|
# 构建 proxy-cli,存放到 src-tauri/sidecar 目录下
|
||||||
|
build-proxy-cli:
|
||||||
|
echo "Building proxy-cli with version {{proxy_version}}" \
|
||||||
|
&& cd "{{proxy_path}}" \
|
||||||
|
&& go build -trimpath -ldflags "-extldflags -s -w -X proxy-service/internal/services.Version={{proxy_version}}" -o "{{rust_path}}/sidecar/paw-core-{{target_triple}}{{bin_ext}}" cmd/client/main.go \
|
||||||
|
&& echo "Finished building proxy-cli, output to {{rust_path}}/sidecar/paw-core-{{target_triple}}{{bin_ext}}"
|
||||||
|
|
||||||
|
# 格式化代码,并且使用clippy检查代码错误
|
||||||
|
lint:
|
||||||
|
cargo fmt --manifest-path "{{system_service_manifest}}" --all
|
||||||
|
cargo clippy --manifest-path "{{system_service_manifest}}" --all-targets --all-features -- -D warnings
|
||||||
|
cargo fmt --manifest-path "{{tauri_manifest}}" --all
|
||||||
|
cargo clippy --manifest-path "{{tauri_manifest}}" --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
# 安装前端依赖
|
||||||
|
install-frontend-dep:
|
||||||
|
pnpm i
|
||||||
|
|
||||||
|
# 构建Paw-GUI,输出到 src-tauri/target/release/bundle 目录下
|
||||||
|
build: build-proxy-cli build-system-service install-frontend-dep
|
||||||
|
pnpm tauri build
|
||||||
|
|
||||||
|
# 启动Paw-GUI开发模式
|
||||||
|
dev: build-system-service install-frontend-dep
|
||||||
|
pnpm tauri dev
|
||||||
|
|
||||||
|
# 清理所有生成的文件
|
||||||
|
clean:
|
||||||
|
cargo clean --manifest-path "{{tauri_manifest}}" # 由于使用了workspace,所以直接清理根项目即可
|
||||||
|
-rm -r "{{rust_path}}/sidecar"
|
||||||
|
|
||||||
|
# 运行所有测试
|
||||||
|
test: build-proxy-cli build-system-service
|
||||||
|
cargo test --manifest-path "{{tauri_manifest}}" --workspace
|
||||||
|
|
||||||
|
proxy_path := justfile_directory() / "../proxy"
|
||||||
|
proxy_version := `if v="$(git tag --list '*paw' | sort -V | tail -n 1)" && [ -n "$v" ]; then echo "$v"; else echo "v0.0.1-$(git rev-parse --short HEAD)"; fi`
|
||||||
|
rust_path := justfile_directory() / "src-tauri"
|
||||||
|
tauri_manifest := rust_path / "Cargo.toml"
|
||||||
|
system_service_manifest := rust_path / "system-service/Cargo.toml"
|
||||||
|
cargo_target_path := rust_path / "target/release"
|
||||||
|
bin_ext := if os() == "windows" { ".exe" } else { "" }
|
||||||
|
target_triple := ```case "$(uname)-$(uname -m)" in "Darwin-x86_64") echo "x86_64-apple-darwin" ;; "Darwin-arm64") echo "aarch64-apple-darwin" ;; "Linux-x86_64") echo "x86_64-unknown-linux-gnu" ;; "MINGW"*"-x86_64") echo "x86_64-pc-windows-msvc" ;; *) echo "Unsupported platform" && exit 1 ;; esac```
|
||||||
84
package.json
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
{
|
||||||
|
"name": "paw-gui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"web:dev": "vite",
|
||||||
|
"web:preview": "vite preview",
|
||||||
|
"web:build": "tsc && vite build",
|
||||||
|
"sidecar:build": "node build-sidecar.mjs",
|
||||||
|
"tauri": "tauri"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.9.1",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
|
"@radix-ui/react-checkbox": "^1.1.3",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
|
"@radix-ui/react-radio-group": "^1.2.2",
|
||||||
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
|
"@radix-ui/react-toast": "^1.2.4",
|
||||||
|
"@reduxjs/toolkit": "^2.5.0",
|
||||||
|
"@tanstack/react-table": "^8.20.6",
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-dialog": "~2",
|
||||||
|
"@tauri-apps/plugin-global-shortcut": "~2",
|
||||||
|
"@tauri-apps/plugin-http": "~2.2.0",
|
||||||
|
"@tauri-apps/plugin-os": "~2",
|
||||||
|
"@tauri-apps/plugin-process": "~2",
|
||||||
|
"@tauri-apps/plugin-shell": "^2",
|
||||||
|
"@tauri-apps/plugin-store": "~2",
|
||||||
|
"@tauri-apps/plugin-websocket": "~2",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"ahooks": "^3.8.4",
|
||||||
|
"antd": "^5.22.7",
|
||||||
|
"auto-zustand-selectors-hook": "^3.0.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "1.0.0",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"echarts": "^5.6.0",
|
||||||
|
"i18next": "^24.2.0",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
|
"react-i18next": "^15.2.0",
|
||||||
|
"react-redux": "^9.2.0",
|
||||||
|
"react-router-dom": "^7.0.2",
|
||||||
|
"react-spinners": "^0.15.0",
|
||||||
|
"react-virtuoso": "^4.12.3",
|
||||||
|
"redux-persist": "^6.0.0",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"uuid": "^11.0.4",
|
||||||
|
"zod": "^3.24.1",
|
||||||
|
"zustand": "^5.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/react": "^18.2.15",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"code-inspector-plugin": "^0.19.1",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"sass": "^1.83.0",
|
||||||
|
"tailwindcss": "^3.4.15",
|
||||||
|
"typescript": "^5.2.2",
|
||||||
|
"unplugin-auto-import": "^0.19.0",
|
||||||
|
"vite": "^5.3.1",
|
||||||
|
"vite-plugin-svgr": "^4.3.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@9.15.3+sha512.1f79bc245a66eb0b07c5d4d83131240774642caaa86ef7d0434ab47c0d16f66b04e21e0c086eb61e62c77efc4d7f7ec071afad3796af64892fae66509173893a"
|
||||||
|
}
|
||||||
5685
pnpm-lock.yaml
generated
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
13
src-tauri/.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
|
||||||
|
# Generated by Tauri
|
||||||
|
# will have schema files for capabilities auto-completion
|
||||||
|
/gen/schemas
|
||||||
|
|
||||||
|
.vscode/*
|
||||||
|
|
||||||
|
sidecar/*
|
||||||
|
|
||||||
|
system-service/.vscode/*
|
||||||
3
src-tauri/.taurignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
sidecar/*
|
||||||
|
|
||||||
|
system-service/.vscode/*
|
||||||
6349
src-tauri/Cargo.lock
generated
Normal file
84
src-tauri/Cargo.toml
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"common", # 共享工具库
|
||||||
|
"system-service", # 服务相关 crate
|
||||||
|
".", # GUI 主程序
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0" # 所有程序的版本号(包括tauri的)
|
||||||
|
authors = ["you"]
|
||||||
|
edition = "2021"
|
||||||
|
description = "Paw Project"
|
||||||
|
|
||||||
|
# 在workspace层面定义共享依赖
|
||||||
|
[workspace.dependencies]
|
||||||
|
anyhow = "1.0.93"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1.41.1", features = ["full"] }
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = { version = "0.3.18", features = ["local-time"] }
|
||||||
|
# specta用于将Rust类型、函数导出到TypeScript
|
||||||
|
specta = "=2.0.0-rc.20"
|
||||||
|
specta-typescript = "0.0.7"
|
||||||
|
tauri-specta = { version = "=2.0.0-rc.20", features = ["derive", "typescript"] }
|
||||||
|
futures-util = "0.3.30"
|
||||||
|
bytes = "1.7.2"
|
||||||
|
|
||||||
|
# Tauri主程序
|
||||||
|
[package]
|
||||||
|
name = "paw-gui"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "paw_gui_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# 子package尽量使用workspace中定义的公共依赖,以保证版本一致
|
||||||
|
anyhow.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["process"] }
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
specta.workspace = true
|
||||||
|
specta-typescript.workspace = true
|
||||||
|
tauri-specta.workspace = true
|
||||||
|
futures-util.workspace = true
|
||||||
|
bytes.workspace = true
|
||||||
|
|
||||||
|
# 特定于GUI的依赖
|
||||||
|
tauri = { version = "2.1.1", features = [ "protocol-asset", "tray-icon", "image-ico", "image-png"] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
reqwest = "0.12.9"
|
||||||
|
elevated-command = "1.1.2"
|
||||||
|
paw-common = { path = "./common" }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-os = "2"
|
||||||
|
tauri-plugin-process = "2"
|
||||||
|
tauri-plugin-http = "2"
|
||||||
|
tauri-plugin-store = "2"
|
||||||
|
tauri-plugin-websocket = "2"
|
||||||
|
|
||||||
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
tauri-plugin-global-shortcut = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
incremental = true # Compile your binary in smaller steps.
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1 # Allows LLVM to perform better optimization.
|
||||||
|
lto = true # Enables link-time-optimizations.
|
||||||
|
opt-level = "s" # Prioritizes small binary size. Use `3` if you prefer speed.
|
||||||
|
strip = true # Ensures debug symbols are removed.
|
||||||
31
src-tauri/Info.plist
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.paw.service</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
|
||||||
|
<!-- 允许不安全的连接,用于开发阶段,发布时需要调整 -->
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsArbitraryLoads</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
|
||||||
|
<!-- Network Extension 权限 -->
|
||||||
|
<key>NetworkExtensions</key>
|
||||||
|
<array>
|
||||||
|
<string>com.apple.networkextension.vpn</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- 防止沙盒限制 -->
|
||||||
|
<key>com.apple.security.app-sandbox</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.network.server</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
36
src-tauri/README.md
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Paw GUi
|
||||||
|
|
||||||
|
Paw 的 GUI 客户端,基于 Tauri 构建。
|
||||||
|
|
||||||
|
有关系统服务部分,请看其[独立的文档](./system-service/README.md),有关整体、前端和构建,请看[上一级的文档](../README.md)
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
- capabilities/ 用于管理窗口组的权限,请看[文档](https://v2.tauri.app/reference/acl/capability/)
|
||||||
|
- icons/ 软件使用的图标
|
||||||
|
- rust-toolchain.toml 固定工具链版本
|
||||||
|
- rustfmt.toml 格式化方案
|
||||||
|
- **tauri.config.json tauri 框架的配置文件,后期可能根据操作系统拆分成三个文件,注意其中的 `build` 选项**
|
||||||
|
- build.rs 构建脚本
|
||||||
|
- system-service 用于管理系统服务,是独立的 package(内含三个 crate)
|
||||||
|
- common 存放一些公共的代码,所有 crate 都依赖它
|
||||||
|
- install-scripts 打包时需要用的脚本,包含win、linux和macos的脚本
|
||||||
|
- src/
|
||||||
|
- main.rs 二进制 crate 入口文件,一般不需要修改
|
||||||
|
- lib.rs 真正的程序入口
|
||||||
|
- cmds.rs 前端可以调用的指令,其中功能指向具体实现模块
|
||||||
|
- core/ 与核心交互模块
|
||||||
|
- service/ 与系统服务交互模块
|
||||||
|
|
||||||
|
## 第三方库依赖
|
||||||
|
|
||||||
|
- [tauri](https://v2.tauri.app/): 基础应用框架
|
||||||
|
- [serde](https://serde.rs/) & serde_json: 序列化/反序列化框架
|
||||||
|
- [specta](https://docs.rs/specta/2.0.0-rc.20/specta/index.html) & specta-typescript: 将 Rust 类型生成为 ts 类型
|
||||||
|
- [tauri-specta](https://docs.rs/tauri-specta/2.0.0-rc.20/tauri_specta/index.html) 生成 `bindings.ts` 中的 command、event 和类型代码,**请使用这个库来创建需要传输的类型、command 和 event**
|
||||||
|
- [reqwest](https://docs.rs/reqwest/latest/reqwest/) HTTP客户端
|
||||||
|
- [anyhow](https://github.com/dtolnay/anyhow) 简化错误处理
|
||||||
|
- [tracing](https://docs.rs/tracing/latest/tracing/) 日志门面库
|
||||||
|
- [tracing-subscriber](https://docs.rs/tracing-subscriber/latest/tracing_subscriber/) 日志实现库
|
||||||
|
- [tokio](https://tokio.rs/tokio/tutorial) 异步运行时,也包含了很多异步实用工具
|
||||||
|
- [elevated-command](https://crates.io/crates/elevated-command) 用于提权安装系统服务
|
||||||
BIN
src-tauri/assets/video/crystal-ball.mp4
Normal file
4
src-tauri/build.rs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
fn main() {
|
||||||
|
// tauri 构建
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
60
src-tauri/capabilities/default.json
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Capability for the main window",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-hide",
|
||||||
|
"core:window:allow-center",
|
||||||
|
"core:window:allow-show",
|
||||||
|
"core:window:allow-set-focus",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-unminimize",
|
||||||
|
"core:window:allow-toggle-maximize",
|
||||||
|
"core:window:allow-unmaximize",
|
||||||
|
"core:window:allow-set-fullscreen",
|
||||||
|
"core:window:allow-start-dragging",
|
||||||
|
"core:app:allow-app-show",
|
||||||
|
"core:app:allow-app-hide",
|
||||||
|
"core:event:default",
|
||||||
|
"core:event:allow-listen",
|
||||||
|
"core:tray:allow-set-icon",
|
||||||
|
"core:tray:allow-set-menu",
|
||||||
|
"core:tray:default",
|
||||||
|
"core:tray:allow-new",
|
||||||
|
"core:tray:allow-get-by-id",
|
||||||
|
"core:tray:allow-set-temp-dir-path",
|
||||||
|
"core:tray:allow-set-icon-as-template",
|
||||||
|
"global-shortcut:allow-is-registered",
|
||||||
|
"global-shortcut:allow-register",
|
||||||
|
"global-shortcut:allow-unregister",
|
||||||
|
"global-shortcut:allow-register-all",
|
||||||
|
"global-shortcut:allow-unregister-all",
|
||||||
|
"dialog:default",
|
||||||
|
"os:default",
|
||||||
|
"process:default",
|
||||||
|
"shell:allow-open",
|
||||||
|
{
|
||||||
|
"identifier": "http:default",
|
||||||
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "http://**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://**"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://*:*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://*:*"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"store:default",
|
||||||
|
"websocket:default",
|
||||||
|
"http:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
src-tauri/common/Cargo.toml
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "paw-common"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
specta.workspace = true
|
||||||
|
specta-typescript.workspace = true
|
||||||
|
tauri-specta.workspace = true
|
||||||
8
src-tauri/common/build.rs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
fn main() {
|
||||||
|
// 这里需要和源代码里对应
|
||||||
|
// 具体位置是 `paw-gui/src-tauri/common/src/core.rs`
|
||||||
|
println!("cargo::rerun-if-env-changed=GRPC_ENDPOINT");
|
||||||
|
println!("cargo::rerun-if-env-changed=COSMOS_ENDPOINT");
|
||||||
|
println!("cargo::rerun-if-env-changed=NODE_SECRET");
|
||||||
|
println!("cargo::rerun-if-env-changed=IS_DEBUG");
|
||||||
|
}
|
||||||
116
src-tauri/common/src/core.rs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
use crate::preclude::LOCALHOST;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::env;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||||
|
pub struct CoreConfig {
|
||||||
|
pub socks_port: u16,
|
||||||
|
pub socks_user: String,
|
||||||
|
pub socks_pass: String,
|
||||||
|
pub api_port: u16,
|
||||||
|
pub secret: String,
|
||||||
|
pub tun: bool,
|
||||||
|
pub log_file_path: String,
|
||||||
|
pub traffic_gen_rule_path: String,
|
||||||
|
pub dsn: String,
|
||||||
|
pub debug: bool,
|
||||||
|
pub address_prefix: String,
|
||||||
|
pub account_name: String,
|
||||||
|
pub grpc_endpoint: String,
|
||||||
|
pub cosmos_endpoint: String,
|
||||||
|
pub node_secret: String,
|
||||||
|
pub passphrase: String,
|
||||||
|
pub data_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CoreConfig {
|
||||||
|
fn default() -> Self {
|
||||||
|
// 获取日志文件路径
|
||||||
|
let data_path = if cfg!(target_os = "windows") {
|
||||||
|
// Windows 使用 APPDATA 目录
|
||||||
|
let mut log_path = env::var("APPDATA")
|
||||||
|
.unwrap_or_else(|_| String::from("C:\\Users\\User\\AppData\\Roaming"));
|
||||||
|
log_path.push_str("\\com.paw.paw-gui\\logs");
|
||||||
|
log_path
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
// macOS 使用 ~/Library/Logs 作为日志目录
|
||||||
|
let mut log_path = PathBuf::from("/var/log");
|
||||||
|
log_path.push("/var/log/com.paw.paw-gui"); // 放在 /Users/Shared/Library/Logs/com.paw.paw-gui
|
||||||
|
log_path.to_string_lossy().to_string()
|
||||||
|
} else if cfg!(target_os = "linux") {
|
||||||
|
// Linux 使用 ~/.local/share 作为日志目录
|
||||||
|
let mut log_path = PathBuf::from("/home/user");
|
||||||
|
log_path.push(".local/share/com.paw.paw-gui/logs");
|
||||||
|
log_path.to_string_lossy().to_string()
|
||||||
|
} else {
|
||||||
|
// 如果无法识别的操作系统,返回默认日志文件路径
|
||||||
|
"logs/com.paw.paw-gui".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如何增加新的环境变量:
|
||||||
|
// 1. 在下面增加对应的代码
|
||||||
|
// 2. 在 `paw-gui/src-tauri/common` 文件夹下的 `build.rs` 中增加条件检查
|
||||||
|
|
||||||
|
// 在发布的情况下,应当从编译期环境变量中获取真实配置,而非默认配置
|
||||||
|
let account_name = env!("ACCOUNT_NAME").to_string();
|
||||||
|
let grpc_endpoint = env!("GRPC_ENDPOINT").to_string();
|
||||||
|
let cosmos_endpoint = env!("COSMOS_ENDPOINT").to_string();
|
||||||
|
let node_secret = env!("NODE_SECRET").to_string();
|
||||||
|
let is_debug = env!("IS_DEBUG") == "true";
|
||||||
|
// !NOTICE: 注意,启动后程序会将配置文件持久化到 `%AppData%\com.paw.paw-gui` 中,这个路径由 tauri 的 store 插件指定
|
||||||
|
// !NOTICE: 如果使用了不同的环境变量构建程序,不但需要在 src-tauri 执行 `cargo clean`,还需要删除这个里面的json文件
|
||||||
|
|
||||||
|
Self {
|
||||||
|
socks_port: 1080,
|
||||||
|
socks_user: "".to_string(),
|
||||||
|
socks_pass: "".to_string(),
|
||||||
|
api_port: 1081,
|
||||||
|
secret: "secret".to_string(),
|
||||||
|
tun: true,
|
||||||
|
log_file_path: "".to_string(), // 使用空字符串,表示输出到标准输出流,让核心日志记录到系统服务的日志中即可
|
||||||
|
traffic_gen_rule_path: "".to_string(),
|
||||||
|
dsn: "".to_string(),
|
||||||
|
debug: is_debug,
|
||||||
|
address_prefix: "cosmos".to_string(),
|
||||||
|
account_name,
|
||||||
|
grpc_endpoint,
|
||||||
|
cosmos_endpoint,
|
||||||
|
node_secret,
|
||||||
|
passphrase: "".to_string(),
|
||||||
|
data_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreConfig {
|
||||||
|
/// 获取用于启动核心的命令行参数
|
||||||
|
pub fn cli_args(&self) -> Vec<String> {
|
||||||
|
let args = vec![
|
||||||
|
format!("-socks-port={}", self.socks_port),
|
||||||
|
format!("-socks-user={}", self.socks_user),
|
||||||
|
format!("-socks-pass={}", self.socks_pass),
|
||||||
|
format!("-api-port={}", self.api_port),
|
||||||
|
format!("-secret={}", self.secret),
|
||||||
|
format!("-tun={}", self.tun),
|
||||||
|
format!("-log-file-path={}", self.log_file_path),
|
||||||
|
format!("-genrule={}", self.traffic_gen_rule_path),
|
||||||
|
format!("-dsn={}", self.dsn),
|
||||||
|
format!("-debug={}", self.debug),
|
||||||
|
format!("-address-prefix={}", self.address_prefix),
|
||||||
|
format!("-account-name={}", self.account_name),
|
||||||
|
format!("-grpc-endpoint={}", self.grpc_endpoint),
|
||||||
|
format!("-cosmos-endpoint={}", self.cosmos_endpoint),
|
||||||
|
format!("-nodeSecret={}", self.node_secret),
|
||||||
|
format!("-passphrase={}", self.passphrase),
|
||||||
|
format!("-data-path={}", self.data_path),
|
||||||
|
];
|
||||||
|
|
||||||
|
args
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn core_api_url(&self) -> String {
|
||||||
|
format!("{}:{}", LOCALHOST, self.api_port)
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src-tauri/common/src/lib.rs
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
mod core;
|
||||||
|
|
||||||
|
pub mod preclude {
|
||||||
|
use std::{env, path::PathBuf};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub use crate::core::CoreConfig;
|
||||||
|
|
||||||
|
/// 系统服务二进制文件的名称
|
||||||
|
pub static SERVICE_BIN_NAME: &str = "paw-system-service";
|
||||||
|
/// 系统服务的名称
|
||||||
|
pub static SERVICE_NAME: &str = "paw-system-service";
|
||||||
|
/// 系统服务的显示名称
|
||||||
|
pub static SERVICE_DISPLAY_NAME: &str = "Paw System Service";
|
||||||
|
/// 系统服务的描述文本
|
||||||
|
pub static SERVICE_DESCRIPTION: &str = "Paw system service";
|
||||||
|
/// 系统服务使用的端口号
|
||||||
|
pub static SYSTEM_SERVICE_PORT: i32 = 33000;
|
||||||
|
/// macOS 上服务的名称
|
||||||
|
pub static SERVICE_MACOS_NAME: &str = "com.paw.service";
|
||||||
|
|
||||||
|
/// 安装服务的可执行文件名称
|
||||||
|
pub static SERVICE_INSTALLER_NAME: &str = "paw-install-service";
|
||||||
|
/// 卸载服务的可执行文件名称
|
||||||
|
pub static SERVICE_UNINSTALLER_NAME: &str = "paw-uninstall-service";
|
||||||
|
|
||||||
|
/// 核心组件(CLI)的名称
|
||||||
|
pub static CORE_NAME: &str = "paw-core";
|
||||||
|
pub static LOCALHOST: &str = "http://127.0.0.1";
|
||||||
|
|
||||||
|
pub fn get_bin_path(s: &str) -> Result<PathBuf> {
|
||||||
|
let bin_ext = if cfg!(windows) { ".exe" } else { "" };
|
||||||
|
let bin_path = env::current_exe()?;
|
||||||
|
let bin_path = bin_path.with_file_name(format!("{s}{bin_ext}"));
|
||||||
|
|
||||||
|
if !bin_path.exists() {
|
||||||
|
bail!("Bin path not exists: {bin_path:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(bin_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn service_url() -> String {
|
||||||
|
format!("{LOCALHOST}:{SYSTEM_SERVICE_PORT}")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||||
|
pub struct CoreResponse<T> {
|
||||||
|
pub code: i32,
|
||||||
|
pub msg: String,
|
||||||
|
pub data: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> CoreResponse<T> {
|
||||||
|
pub fn ok(data: T) -> Self {
|
||||||
|
Self {
|
||||||
|
code: 0,
|
||||||
|
msg: "ok".to_string(),
|
||||||
|
data,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn err<E: std::fmt::Debug>(error: E) -> Self
|
||||||
|
where
|
||||||
|
T: Default,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
code: 1,
|
||||||
|
msg: format!("Operation failed: {:?}", error),
|
||||||
|
data: T::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
src-tauri/icons/logo.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
6
src-tauri/install-scripts/linux-postinstall.sh
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
chmod +x /usr/bin/paw-install-service
|
||||||
|
chmod +x /usr/bin/paw-uninstall-service
|
||||||
|
chmod +x /usr/bin/paw-system-service
|
||||||
|
|
||||||
|
/usr/bin/paw-install-service
|
||||||
2
src-tauri/install-scripts/linux-preremove.sh
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
/usr/bin/paw-uninstall-service
|
||||||
9
src-tauri/install-scripts/linux-template.desktop
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Categories={{{categories}}}
|
||||||
|
Comment={{{comment}}}
|
||||||
|
Exec={{{exec}}}
|
||||||
|
StartupWMClass={{{exec}}}
|
||||||
|
Icon={{{icon}}}
|
||||||
|
Name={{{name}}}
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
21
src-tauri/install-scripts/win-hooks.nsi
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
; Tauri document: https://v2.tauri.app/distribute/windows-installer/#customizing-the-nsis-installer
|
||||||
|
; NSIS Document: https://nsis.sourceforge.io/Docs/Chapter4.html
|
||||||
|
|
||||||
|
; Uninstall paw-system-service if it exists
|
||||||
|
!macro NSIS_HOOK_PREINSTALL
|
||||||
|
IfFileExists "$INSTDIR\paw-uninstall-service.exe" 0 skip_uninstall
|
||||||
|
DetailPrint "Uninstalling previous service..."
|
||||||
|
ExecWait '"$INSTDIR\paw-uninstall-service.exe"'
|
||||||
|
Sleep 2000
|
||||||
|
skip_uninstall:
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
; Install paw-system-service
|
||||||
|
!macro NSIS_HOOK_POSTINSTALL
|
||||||
|
Exec '"$INSTDIR\paw-install-service.exe"'
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
; Uninstall paw-system-service
|
||||||
|
!macro NSIS_HOOK_PREUNINSTALL
|
||||||
|
Exec '"$INSTDIR\paw-uninstall-service.exe"'
|
||||||
|
!macroend
|
||||||
3
src-tauri/rust-toolchain.toml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "stable" # 指定 Rust 工具链的版本,比如 "stable", "nightly", 或具体版本如 "1.74.0"
|
||||||
|
components = ["rustfmt", "clippy", "rust-analyzer"]
|
||||||
2
src-tauri/rustfmt.toml
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
reorder_imports = false
|
||||||
|
tab_spaces = 4
|
||||||
166
src-tauri/src/cmds.rs
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
use tauri::AppHandle;
|
||||||
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
core::{
|
||||||
|
self,
|
||||||
|
api::{self},
|
||||||
|
},
|
||||||
|
service,
|
||||||
|
utils::get_config,
|
||||||
|
};
|
||||||
|
use paw_common::preclude::CoreConfig;
|
||||||
|
|
||||||
|
/// 前端调用 command 返回的错误,错误类型全部映射到字符串
|
||||||
|
type CommandResult<T = ()> = Result<T, String>;
|
||||||
|
|
||||||
|
/// 把`anyhow::Result<T>`转为 `Result<T, String>` 类型,
|
||||||
|
///
|
||||||
|
/// 接受一个表达式作为输入,该表达式通常是一个可能返回 `anyhow::Result<T>` 类型的操作,
|
||||||
|
/// 如果操作成功,则返回 `CommandResult::Ok(T)`,如果操作失败,则将错误转换为 `CommandResult::Err(错误信息字符串)`
|
||||||
|
fn cmd_result<T>(r: anyhow::Result<T>) -> CommandResult<T> {
|
||||||
|
match r {
|
||||||
|
Ok(v) => Ok(v),
|
||||||
|
Err(e) => {
|
||||||
|
error!(target: "app", "{:?}", e);
|
||||||
|
Err(format!("{:?}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核心:获取节点列表,可测试核心是否可用
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_nodes(app: AppHandle) -> CommandResult<Vec<api::ProxyNodeInfo>> {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(core::api::get_nodes(config).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核心:开启代理
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn enable_proxy(app: AppHandle) -> CommandResult {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(core::api::enable_sysproxy(config).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核心:关闭代理
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn disable_proxy(app: AppHandle) -> CommandResult {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(core::api::disable_sysproxy(config).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核心:选择节点
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn select_node(name: String, app: AppHandle) -> CommandResult {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(core::api::select_node(config, name).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核心:设置电路规则
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn create_circuit(
|
||||||
|
circuit: core::api::CircuitRequest,
|
||||||
|
app: AppHandle,
|
||||||
|
) -> CommandResult<Vec<String>> {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(core::api::create_circuit(config, circuit).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核心:删除电路规则
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn delete_circuit(path: String, is_prefix: bool, app: AppHandle) -> CommandResult {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(core::api::delete_circuit(config, path, is_prefix).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取代理服务状态
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_proxy(app: AppHandle) -> CommandResult<String> {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(core::api::get_proxy(config).await)
|
||||||
|
}
|
||||||
|
/// 获取代理核心版本
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_version(app: AppHandle) -> CommandResult<String> {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(core::api::get_version(config).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务安装器:安装系统服务
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn install_service() -> CommandResult {
|
||||||
|
cmd_result(service::install::install_service())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务安装器:卸载系统服务
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn uninstall_service() -> CommandResult {
|
||||||
|
cmd_result(service::install::uninstall_service())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统服务:终止服务
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn stop_service() -> CommandResult {
|
||||||
|
cmd_result(service::api::stop_service().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统服务:启动核心
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn start_core(app: AppHandle) -> CommandResult {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(service::api::start_core(config).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统服务:停止核心
|
||||||
|
/// 先通过核心停止自身,再使用系统服务停止核心
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn stop_core(app: AppHandle) -> CommandResult {
|
||||||
|
// 先尝试让核心自己终止,不论成功与否,都继续尝试使用系统服务终止
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
if let Err(e) = cmd_result(core::api::stop_core(config).await) {
|
||||||
|
error!(target: "app", "{e}");
|
||||||
|
}
|
||||||
|
cmd_result(service::api::stop_core().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统服务:重启核心
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn restart_core(app: AppHandle) -> CommandResult {
|
||||||
|
let config = cmd_result(get_config(&app))?;
|
||||||
|
cmd_result(service::api::restart_core(config).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 系统服务:获取系统服务版本号
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_service_version() -> CommandResult<String> {
|
||||||
|
cmd_result(service::api::service_version().await)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 核心:获取一份默认的核心配置文件
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_default_core_config() -> CoreConfig {
|
||||||
|
CoreConfig::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取动态路由端点
|
||||||
|
#[tauri::command]
|
||||||
|
#[specta::specta]
|
||||||
|
pub async fn get_dynamic_routing_endpoint() -> CommandResult<String> {
|
||||||
|
std::env::var("DYNAMIC_ROUTING_SERVER_URL").map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
246
src-tauri/src/core/api.rs
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::{bail, Ok, Result};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
use paw_common::preclude::{CoreConfig, CoreResponse};
|
||||||
|
|
||||||
|
// API文档 https://github.acme.red/wuzhen/timi/blob/main/apps/paw/proxy/doc/clientAPI.md
|
||||||
|
|
||||||
|
/// 节点信息
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||||
|
pub struct ProxyNodeInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub country_code: String,
|
||||||
|
pub country_name: String,
|
||||||
|
pub country_name_zh: String,
|
||||||
|
pub city_name: String,
|
||||||
|
pub city_name_zh: String,
|
||||||
|
pub delay: f64,
|
||||||
|
pub download: f64,
|
||||||
|
pub upload: f64,
|
||||||
|
pub survive_score: f64,
|
||||||
|
pub exit: bool,
|
||||||
|
#[serde(rename = "use")] // 可以通过 serde 重命名字段为 'use',但是 Rust 内部保持不同的名称
|
||||||
|
#[specta(rename = "use")] // 让 specta 映射到前端接口时保持 'use'
|
||||||
|
pub is_used: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||||
|
pub struct CircuitRequest {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub inbound: String,
|
||||||
|
pub outbound: String,
|
||||||
|
pub multi_hop: u32,
|
||||||
|
pub fallback: bool,
|
||||||
|
pub rule_path: Option<String>,
|
||||||
|
pub is_prefix: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, specta::Type)]
|
||||||
|
pub struct CircuitInfo {
|
||||||
|
pub path: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type GetNodesResponse = CoreResponse<Vec<ProxyNodeInfo>>;
|
||||||
|
pub type CircuitInfoResponse = CoreResponse<CircuitInfo>;
|
||||||
|
// pub type PostNodesResponse = CoreResponse<Option<()>>;
|
||||||
|
// pub type PostProxyResponse = CoreResponse<Option<()>>;
|
||||||
|
// pub type DeleteProxyResponse = CoreResponse<Option<()>>;
|
||||||
|
// pub type DeleteSystemResponse = CoreResponse<Option<()>>;
|
||||||
|
|
||||||
|
pub fn config_header(config: CoreConfig) -> reqwest::header::HeaderMap {
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
reqwest::header::AUTHORIZATION,
|
||||||
|
format!("Bearer {}", config.secret).parse().unwrap(), // safe: secret_key 总是是安全的
|
||||||
|
);
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取所有可用节点信息,可以用来当作测试健康度的函数
|
||||||
|
///
|
||||||
|
/// get /nodes
|
||||||
|
pub async fn get_nodes(config: CoreConfig) -> Result<Vec<ProxyNodeInfo>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/nodes", config.core_api_url()))
|
||||||
|
.headers(config_header(config))
|
||||||
|
.timeout(Duration::from_millis(100)) // 由于正常情况下,节点获取速度很快,而且是本地API,为了加快检查核心是否正常,设置较短的超时
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
let result: GetNodesResponse = response.json().await?;
|
||||||
|
debug!("Successfully got nodes: {:?}", result.data);
|
||||||
|
Ok(result.data)
|
||||||
|
} else {
|
||||||
|
error!("Failed to get nodes: {}", response.text().await?);
|
||||||
|
bail!("Failed to get nodes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 选择节点
|
||||||
|
///
|
||||||
|
/// post /nodes/{name}
|
||||||
|
pub async fn select_node(config: CoreConfig, name: String) -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/nodes/{}", config.core_api_url(), name))
|
||||||
|
.headers(config_header(config))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
info!("Successfully selected node.");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
error!("Failed to select node: {}", response.text().await?);
|
||||||
|
bail!("Failed to select node")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 开启代理
|
||||||
|
///
|
||||||
|
/// post /proxy
|
||||||
|
pub async fn enable_sysproxy(config: CoreConfig) -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/proxy", config.core_api_url()))
|
||||||
|
.headers(config_header(config))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
info!("Successfully enabled proxy.");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
error!("Failed to enable proxy: {}", response.text().await?);
|
||||||
|
bail!("Failed to enable proxy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭代理
|
||||||
|
///
|
||||||
|
/// delete /proxy
|
||||||
|
pub async fn disable_sysproxy(config: CoreConfig) -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.delete(format!("{}/proxy", config.core_api_url()))
|
||||||
|
.headers(config_header(config))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
info!("successfully disable proxy.");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
error!("Failed to disable proxy: {}", response.text().await?);
|
||||||
|
bail!("Failed to disable proxy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 关闭程序
|
||||||
|
///
|
||||||
|
/// delete /system
|
||||||
|
pub async fn stop_core(config: CoreConfig) -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.delete(format!("{}/system", config.core_api_url()))
|
||||||
|
.headers(config_header(config))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
info!("successfully stop core.");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
error!("Failed to stop core: {}", response.text().await?);
|
||||||
|
bail!("Failed to stop core")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置多跳电路
|
||||||
|
///
|
||||||
|
/// post /circuit
|
||||||
|
pub async fn create_circuit(config: CoreConfig, circuit: CircuitRequest) -> Result<Vec<String>> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/circuit", config.core_api_url()))
|
||||||
|
.headers(config_header(config))
|
||||||
|
.json(&circuit)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
// info!("successfully create circuit: {}", response.text().await?);
|
||||||
|
let result: CircuitInfoResponse = response.json().await?;
|
||||||
|
Ok(result.data.path)
|
||||||
|
} else {
|
||||||
|
error!("Failed to create circuit: {}", response.text().await?);
|
||||||
|
bail!("Failed to create circuit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除电路规则
|
||||||
|
///
|
||||||
|
/// delete /circuit
|
||||||
|
pub async fn delete_circuit(config: CoreConfig, path: String, is_prefix: bool) -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.delete(format!("{}/circuit", config.core_api_url()))
|
||||||
|
.query(&[("path", path), ("is_prefix", is_prefix.to_string())])
|
||||||
|
.headers(config_header(config))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
info!("successfully delete circuit.");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
error!("Failed to delete circuit: {}", response.text().await?);
|
||||||
|
bail!("Failed to delete circuit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取代理服务状态
|
||||||
|
///
|
||||||
|
/// get /proxy
|
||||||
|
pub async fn get_proxy(config: CoreConfig) -> Result<String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/proxy", config.core_api_url()))
|
||||||
|
.headers(config_header(config))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
info!("successfully get_proxy.");
|
||||||
|
let result: CoreResponse<String> = response.json().await?;
|
||||||
|
Ok(result.data)
|
||||||
|
} else {
|
||||||
|
error!("Failed to get_proxy: {}", response.text().await?);
|
||||||
|
bail!("Failed to get_proxy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取代理核心版本
|
||||||
|
///
|
||||||
|
/// get /version
|
||||||
|
pub async fn get_version(config: CoreConfig) -> Result<String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/version", config.core_api_url()))
|
||||||
|
.headers(config_header(config))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if response.status().is_success() {
|
||||||
|
info!("successfully get_version.");
|
||||||
|
let result: CoreResponse<String> = response.json().await?;
|
||||||
|
Ok(result.data)
|
||||||
|
} else {
|
||||||
|
error!("Failed to get_version : {}", response.text().await?);
|
||||||
|
bail!("Failed to get_version")
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src-tauri/src/core/mod.rs
Normal file
@ -0,0 +1 @@
|
|||||||
|
pub mod api;
|
||||||
131
src-tauri/src/lib.rs
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
mod cmds;
|
||||||
|
mod core;
|
||||||
|
mod service;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tauri::RunEvent;
|
||||||
|
use tracing::{error, info, level_filters::LevelFilter};
|
||||||
|
use tauri_specta::{collect_commands, Builder};
|
||||||
|
use utils::get_config;
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() -> Result<()> {
|
||||||
|
// 初始化日志
|
||||||
|
init_log();
|
||||||
|
|
||||||
|
// 在这里注册所有需要用的 command
|
||||||
|
let builder = Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||||
|
cmds::get_nodes,
|
||||||
|
cmds::enable_proxy,
|
||||||
|
cmds::disable_proxy,
|
||||||
|
cmds::select_node,
|
||||||
|
cmds::create_circuit,
|
||||||
|
cmds::delete_circuit,
|
||||||
|
cmds::install_service,
|
||||||
|
cmds::uninstall_service,
|
||||||
|
cmds::stop_service,
|
||||||
|
cmds::start_core,
|
||||||
|
cmds::stop_core,
|
||||||
|
cmds::restart_core,
|
||||||
|
cmds::get_service_version,
|
||||||
|
cmds::get_proxy,
|
||||||
|
cmds::get_version,
|
||||||
|
cmds::get_default_core_config,
|
||||||
|
cmds::get_dynamic_routing_endpoint
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 只在 debug 模式下为前端生成代码
|
||||||
|
// 生成文件顶部需要禁用 ts 检查,否则容易导致构建失败
|
||||||
|
// 如果构建debug 模式,请注释掉这段代码,否则debug包无法使用
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
builder
|
||||||
|
.export(
|
||||||
|
specta_typescript::Typescript::default().header(
|
||||||
|
"
|
||||||
|
// This file is automatically generated. Do not edit manually.
|
||||||
|
/* eslint-disable */
|
||||||
|
/* tslint:disable */
|
||||||
|
// @ts-nocheck",
|
||||||
|
),
|
||||||
|
"../src/bindings.ts",
|
||||||
|
)
|
||||||
|
.expect("Failed to export typescript bindings");
|
||||||
|
|
||||||
|
// 构建 tauri 程序
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_store::Builder::new().build())
|
||||||
|
.plugin(tauri_plugin_websocket::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.plugin(tauri_plugin_os::init())
|
||||||
|
.plugin(tauri_plugin_process::init())
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|_app, _args, _cwd| {}))
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||||
|
.invoke_handler(builder.invoke_handler())
|
||||||
|
.setup(move |app| {
|
||||||
|
// 注册事件
|
||||||
|
builder.mount_events(app);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.build(tauri::generate_context!())
|
||||||
|
.expect("error while build tauri application")
|
||||||
|
.run(|app, event| {
|
||||||
|
if let RunEvent::Exit = event {
|
||||||
|
let config = get_config(app).unwrap_or_default();
|
||||||
|
// 程序退出时需要清理掉系统代理、杀死核心进程
|
||||||
|
tauri::async_runtime::block_on(async {
|
||||||
|
// 每个步骤不论是否有异常,都要执行
|
||||||
|
if let Err(e) = core::api::disable_sysproxy(config.clone()).await {
|
||||||
|
error!("Failed to disable system proxy: {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = core::api::stop_core(config).await {
|
||||||
|
error!("Failed to stop core(core): {}", e);
|
||||||
|
}
|
||||||
|
if let Err(e) = service::api::stop_core().await {
|
||||||
|
error!("Failed to stop core(service): {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_log() {
|
||||||
|
use tracing_subscriber::{self, fmt};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
// 目前只记录到终端
|
||||||
|
let console_layer = fmt::layer().with_timer(fmt::time::LocalTime::rfc_3339());
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(LevelFilter::INFO)
|
||||||
|
.with(console_layer)
|
||||||
|
.init();
|
||||||
|
info!("Log initialization successful");
|
||||||
|
|
||||||
|
// 将 panic 信息记录到日志中
|
||||||
|
std::panic::set_hook(Box::new(|panic_info| {
|
||||||
|
// safe: 文档说 This method will currently always return Some, but this may change in future versions.
|
||||||
|
let location = panic_info.location().unwrap(); // 可以使用 unwrap_or_else() 处理 location 为 None 的情况
|
||||||
|
|
||||||
|
// 获取 panic 的原因
|
||||||
|
// 格式化的 panic 和 expect 输出的是 String,而 panic! 宏直接输出的是 &str,都需要进行处理
|
||||||
|
let message = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
|
||||||
|
(*s).to_string()
|
||||||
|
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
|
||||||
|
s.clone()
|
||||||
|
} else {
|
||||||
|
// 未知情况,尝试通过 to_string() 转换
|
||||||
|
panic_info.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
error!(
|
||||||
|
message = "Application panicked.",
|
||||||
|
panic.message = message,
|
||||||
|
panic.file = location.file(),
|
||||||
|
panic.line = location.line(),
|
||||||
|
panic.column = location.column(),
|
||||||
|
);
|
||||||
|
}));
|
||||||
|
}
|
||||||
6
src-tauri/src/main.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
paw_gui_lib::run()
|
||||||
|
}
|
||||||
100
src-tauri/src/service/api.rs
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
use anyhow::{bail, Ok, Result};
|
||||||
|
use tracing::{error, info};
|
||||||
|
use paw_common::preclude::*;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// 获取当前运行的系统服务版本
|
||||||
|
///
|
||||||
|
/// get /service/version
|
||||||
|
pub async fn service_version() -> Result<String> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.get(format!("{}/service/version", service_url()))
|
||||||
|
.timeout(Duration::from_millis(100)) // 由于正常情况下,系统服务响应非常快,为了加快检查系统服务是否正常,设置较短的超时
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
let result: CoreResponse<String> = response.json().await?;
|
||||||
|
Ok(result.data)
|
||||||
|
} else {
|
||||||
|
bail!("Failed to get version: {}", response.text().await?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止系统服务
|
||||||
|
///
|
||||||
|
/// post /service/stop
|
||||||
|
pub async fn stop_service() -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/service/stop", service_url()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
let _result: CoreResponse<()> = response.json().await?;
|
||||||
|
info!("Successfully stop service.");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
error!("Failed to stop service: {}", response.text().await?);
|
||||||
|
bail!("Failed to stop service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动核心
|
||||||
|
///
|
||||||
|
/// post /core/start
|
||||||
|
pub async fn start_core(config: CoreConfig) -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/core/start", service_url()))
|
||||||
|
.json(&config)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
let _result: CoreResponse<()> = response.json().await?;
|
||||||
|
info!("Successfully start core.");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
error!("Failed to start core: {}", response.text().await?);
|
||||||
|
bail!("Failed to start core")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 停止核心
|
||||||
|
///
|
||||||
|
/// post /core/stop
|
||||||
|
pub async fn stop_core() -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/core/stop", service_url()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
let _result: CoreResponse<()> = response.json().await?;
|
||||||
|
info!("Successfully stop core.");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
error!("Failed to stop core: {}", response.text().await?);
|
||||||
|
bail!("Failed to stop core")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重启核心
|
||||||
|
///
|
||||||
|
/// post /core/restart
|
||||||
|
pub async fn restart_core(config: CoreConfig) -> Result<()> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(format!("{}/core/restart", service_url()))
|
||||||
|
.json(&config)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
let _result: CoreResponse<()> = response.json().await?;
|
||||||
|
info!("Successfully restart core.");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
error!("Failed to restart core: {}", response.text().await?);
|
||||||
|
bail!("Failed to restart core")
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src-tauri/src/service/install.rs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
use anyhow::{bail, Result};
|
||||||
|
use elevated_command::Command as SudoCommand;
|
||||||
|
use std::process::Command as StdCommand;
|
||||||
|
use tracing::{debug, error, info};
|
||||||
|
|
||||||
|
use paw_common::preclude::*;
|
||||||
|
|
||||||
|
// 关于操作系统的注释:
|
||||||
|
// 在 Linux 上,提权依赖 pkexec,因此在 WSL 下,需要先安装 polkit,而且需要有桌面环境
|
||||||
|
|
||||||
|
/// 判断命令执行是否成功
|
||||||
|
///
|
||||||
|
/// 在 Windows 上,返回码为 42 表示成功,其他操作系统则为 0。
|
||||||
|
/// Windows 下的返回码的意义可以参考[文档](https://learn.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shellexecutew#return-value)
|
||||||
|
/// > If the function succeeds, it returns a value greater than 32
|
||||||
|
fn is_success(status: &std::process::ExitStatus) -> bool {
|
||||||
|
#[cfg(windows)]
|
||||||
|
return status.code() == Some(42);
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
return status.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn install_service() -> Result<()> {
|
||||||
|
let bin_path = get_bin_path(SERVICE_INSTALLER_NAME)?;
|
||||||
|
debug!("Install-service binary path resolved to: {:?}", bin_path);
|
||||||
|
|
||||||
|
let cmd = StdCommand::new(bin_path);
|
||||||
|
let output = SudoCommand::new(cmd).output()?;
|
||||||
|
info!(target:"Service-Installer","{}",String::from_utf8_lossy(&output.stdout));
|
||||||
|
|
||||||
|
let status = output.status;
|
||||||
|
|
||||||
|
if !is_success(&status) {
|
||||||
|
error!("Service installation failed.");
|
||||||
|
bail!("Service installation failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Service installed successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn uninstall_service() -> Result<()> {
|
||||||
|
let bin_path = get_bin_path(SERVICE_UNINSTALLER_NAME)?;
|
||||||
|
debug!("Uninstall-service binary path resolved to: {:?}", bin_path);
|
||||||
|
|
||||||
|
let cmd = StdCommand::new(bin_path);
|
||||||
|
let output = SudoCommand::new(cmd).output()?;
|
||||||
|
info!(target:"Service-Uninstaller","{}",String::from_utf8_lossy(&output.stdout));
|
||||||
|
|
||||||
|
let status = output.status;
|
||||||
|
|
||||||
|
if !is_success(&status) {
|
||||||
|
error!("Service installation failed.");
|
||||||
|
bail!("Service installation failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Service uninstalled successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
2
src-tauri/src/service/mod.rs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
pub mod api;
|
||||||
|
pub mod install;
|
||||||
12
src-tauri/src/utils.rs
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
use paw_common::preclude::CoreConfig;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
|
pub fn get_config(app: &AppHandle) -> anyhow::Result<CoreConfig> {
|
||||||
|
let store = app.store("core_config.json")?;
|
||||||
|
let value = store
|
||||||
|
.get("core_config")
|
||||||
|
.map(|v| serde_json::from_value(v.clone()).unwrap_or_default())
|
||||||
|
.unwrap_or_default();
|
||||||
|
anyhow::Ok(value)
|
||||||
|
}
|
||||||
35
src-tauri/system-service/Cargo.toml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
[package]
|
||||||
|
name = "system-service"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
description.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
|
||||||
|
# 特定于system-service的依赖
|
||||||
|
axum = "0.7.9"
|
||||||
|
paw-common = { path = "../common" }
|
||||||
|
|
||||||
|
# Windows平台依赖,主要用于注册服务,将日志输出到文件
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows-service = "0.7.0"
|
||||||
|
tracing-appender = "0.2.3"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "paw-system-service"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "paw-install-service"
|
||||||
|
path = "src/installer/install.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "paw-uninstall-service"
|
||||||
|
path = "src/installer/uninstall.rs"
|
||||||
33
src-tauri/system-service/README.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Paw GUi
|
||||||
|
|
||||||
|
Paw 的系统服务及其安装/卸载器
|
||||||
|
|
||||||
|
此处 `install.rs` 和 `uninstall.rs` 中使用的 `Command` 都是同步版本,而其他属于服务部分的`Command`都是异步版本。
|
||||||
|
|
||||||
|
同理,`install.rs` 和 `uninstall.rs` 中使用的日志均为直接 `println!`,而服务部分依赖于 `tracing` 输出到标准输出流,从而被操作系统的服务日志系统记录(Windows下输出到文件)。因此,三个系统的服务日志查看方式如下:
|
||||||
|
|
||||||
|
- Linux: `sudo journalctl -u paw-system-service` 其中具体名称由 `common` 中的 `SERVICE_NAME` 决定
|
||||||
|
- macOS: `/var/log/com.paw.service.log` 其中具体名称由 `common` 中的 `SERVICE_MACOS_NAME` 决定
|
||||||
|
- Windows: 文件在 `软件所在目录/logs/` 下
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
- src/
|
||||||
|
- main.rs 系统服务的入口文件
|
||||||
|
- api.rs 系统服务的API接口
|
||||||
|
- installer/ 系统服务安装/卸载器
|
||||||
|
- install.rs 系统服务安装器
|
||||||
|
- uninstall.rs 系统服务卸载器
|
||||||
|
- template/ 系统服务模板
|
||||||
|
- helper.plist macOS 的系统服务
|
||||||
|
- systemctl.txt Linux 的系统服务
|
||||||
|
|
||||||
|
## 第三方库依赖
|
||||||
|
|
||||||
|
- [serde](https://serde.rs/) & serde_json: 序列化/反序列化框架
|
||||||
|
- [anyhow](https://github.com/dtolnay/anyhow) 简化错误处理
|
||||||
|
- [tokio](https://tokio.rs/tokio/tutorial) 异步运行时,也包含了很多异步实用工具
|
||||||
|
- [axum](https://crates.io/crates/axum) HTTP服务器
|
||||||
|
- [windows-service](https://crates.io/crates/windows-service) Windows 系统服务控制框架,用于安装、卸载、修改系统服务
|
||||||
|
- [tracing](https://crates.io/crates/tracing) [tracing-subscriber](https://crates.io/crates/tracing-subscriber) 记录系统服务的日志
|
||||||
|
- [tracing-appender](https://crates.io/crates/tracing-appender/) 将日志输出到文件,仅 Windows 使用
|
||||||
69
src-tauri/system-service/src/api.rs
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
use axum::Json;
|
||||||
|
use tracing::{debug, info};
|
||||||
|
|
||||||
|
use crate::core::CoreManager;
|
||||||
|
use crate::utils::IntoResponse;
|
||||||
|
use paw_common::preclude::*;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub async fn stop_service() -> Json<CoreResponse<()>> {
|
||||||
|
use crate::utils::close_core_and_service;
|
||||||
|
|
||||||
|
info!("Received stop service request.");
|
||||||
|
Json(close_core_and_service().await.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub async fn stop_service() -> Json<CoreResponse<()>> {
|
||||||
|
use tokio::process::Command;
|
||||||
|
info!("Received stop service request.");
|
||||||
|
Json(
|
||||||
|
Command::new("systemctl")
|
||||||
|
.args(&["stop", &format!("{}.service", SERVICE_NAME)])
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.into_response(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub async fn stop_service() -> Json<CoreResponse<()>> {
|
||||||
|
use tokio::process::Command;
|
||||||
|
info!("Received stop service request.");
|
||||||
|
Json(
|
||||||
|
Command::new("launchctl")
|
||||||
|
.args(&["stop", &format!("system/{}", SERVICE_MACOS_NAME)])
|
||||||
|
.spawn()
|
||||||
|
.map(|_| ())
|
||||||
|
.into_response(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_core(Json(config): Json<CoreConfig>) -> Json<CoreResponse<()>> {
|
||||||
|
info!("Received start core request.");
|
||||||
|
Json(
|
||||||
|
CoreManager::instance()
|
||||||
|
.start_core(config)
|
||||||
|
.await
|
||||||
|
.into_response(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
pub async fn stop_core() -> Json<CoreResponse<()>> {
|
||||||
|
info!("Received stop core request.");
|
||||||
|
Json(CoreManager::instance().stop_core().await.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn restart_core(Json(config): Json<CoreConfig>) -> Json<CoreResponse<()>> {
|
||||||
|
info!("Received restart core request.");
|
||||||
|
Json(
|
||||||
|
CoreManager::instance()
|
||||||
|
.restart_core(config)
|
||||||
|
.await
|
||||||
|
.into_response(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_service_version() -> Json<CoreResponse<String>> {
|
||||||
|
debug!("Received get service version request.");
|
||||||
|
Json(CoreResponse::ok(env!("CARGO_PKG_VERSION").to_string()))
|
||||||
|
}
|
||||||
184
src-tauri/system-service/src/core/manager.rs
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
use std::{
|
||||||
|
process::Stdio,
|
||||||
|
sync::{Arc, OnceLock},
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncBufReadExt, BufReader},
|
||||||
|
process::Child,
|
||||||
|
sync::Mutex,
|
||||||
|
};
|
||||||
|
use tokio::process::Command as AsyncCommand;
|
||||||
|
use anyhow::{bail, Context, Ok, Result};
|
||||||
|
|
||||||
|
use paw_common::preclude::{get_bin_path, CORE_NAME, CoreConfig};
|
||||||
|
use tracing::{info, error, debug};
|
||||||
|
|
||||||
|
/// 用于表示核心状态
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub enum CoreState {
|
||||||
|
/// 核心未启动
|
||||||
|
#[default]
|
||||||
|
Stopped,
|
||||||
|
/// 核心已启动,由于进程状态不会自动更新,所以使用前需要 `try_wait` 来确认状态
|
||||||
|
Running(Child),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CoreManager 用于管理程序的 SideCar 二进制文件,也就是所谓核心,使用`CoreManager::instance()`获取全局单例
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct CoreManager {
|
||||||
|
/// core 是否正在运行,默认值为 false
|
||||||
|
pub core_state: Arc<Mutex<CoreState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CoreManager {
|
||||||
|
/// 获取`CoreManager`的全局单例,如果要使用的话需要先执行`init`方法启动核心
|
||||||
|
pub fn instance() -> &'static Self {
|
||||||
|
static INSTANCE: OnceLock<CoreManager> = OnceLock::new();
|
||||||
|
|
||||||
|
INSTANCE.get_or_init(|| {
|
||||||
|
// 启动一个异步任务来定期检测核心状态
|
||||||
|
// 只有在第一次获取时启动该任务
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// 防止 *nix 系统下出现僵尸进程(try_wait可以回收僵尸进程),同时可以定期检测核心状态
|
||||||
|
loop {
|
||||||
|
let manager = CoreManager::instance();
|
||||||
|
let mut state = manager.core_state.lock().await;
|
||||||
|
if let CoreState::Running(child) = &mut *state {
|
||||||
|
match child.try_wait() {
|
||||||
|
std::result::Result::Ok(Some(status)) => {
|
||||||
|
// 进程已退出,记录状态并更新 CoreState
|
||||||
|
debug!("Core process exited with status: {:?}", status);
|
||||||
|
*state = CoreState::Stopped;
|
||||||
|
}
|
||||||
|
std::result::Result::Ok(None) => {}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to check child process status: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
CoreManager::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动核心并且更新`CoreManager`状态
|
||||||
|
pub async fn start_core(&self, config: CoreConfig) -> Result<()> {
|
||||||
|
// 检查核心是否正在运行
|
||||||
|
info!("Checking if core is already running...");
|
||||||
|
if let CoreState::Running(_) = &*self.core_state.lock().await {
|
||||||
|
info!("Core is already running.");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
info!("Starting core...");
|
||||||
|
// 构造核心子进程路径,并且启动
|
||||||
|
let args = config.cli_args();
|
||||||
|
let bin_path = get_bin_path(CORE_NAME)?;
|
||||||
|
info!("Core binary path resolved to: {:?}", bin_path);
|
||||||
|
info!("Core CLI args: {:?}", args);
|
||||||
|
let mut child = match AsyncCommand::new(bin_path)
|
||||||
|
.args(args)
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Result::Ok(child) => {
|
||||||
|
info!("Core process successfully spawned.");
|
||||||
|
child
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to spawn core process: {:?}", e);
|
||||||
|
return Err(e.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("Core process started.");
|
||||||
|
|
||||||
|
// 获取子进程的 stdout 和 stderr 将其打印到 GUI 的日志中
|
||||||
|
let stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.context("Failed to open core process stdout")?;
|
||||||
|
let stderr = child
|
||||||
|
.stderr
|
||||||
|
.take()
|
||||||
|
.context("Failed to open core process stderr")?;
|
||||||
|
// 将核心标准输出和错误输出重定向到日志中
|
||||||
|
let _stdout_task = tokio::spawn(async move {
|
||||||
|
let mut reader = BufReader::new(stdout).lines();
|
||||||
|
while let Some(line) = reader.next_line().await.unwrap_or(None) {
|
||||||
|
info!(target:"core", "{}", line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _stderr_task = tokio::spawn(async move {
|
||||||
|
let mut reader = BufReader::new(stderr).lines();
|
||||||
|
while let Some(line) = reader.next_line().await.unwrap_or(None) {
|
||||||
|
error!(target:"core", "{}", line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
*self.core_state.lock().await = CoreState::Running(child);
|
||||||
|
info!("Core process started and running state updated.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 通过HTTP请求要求核心退出,再尝试关闭核心进程,更新`CoreManager`状态
|
||||||
|
pub async fn stop_core(&self) -> Result<()> {
|
||||||
|
// 尝试关闭核心进程,并更新核心状态。
|
||||||
|
let mut core_state = self.core_state.lock().await;
|
||||||
|
|
||||||
|
let process = match &mut *core_state {
|
||||||
|
CoreState::Stopped => {
|
||||||
|
// 如果已经停止,直接返回
|
||||||
|
info!("Attempted to stop the core, but it is already stopped.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
CoreState::Running(process) => process,
|
||||||
|
};
|
||||||
|
|
||||||
|
info!("Core process is running, attempting to stop it.");
|
||||||
|
|
||||||
|
// 处理进程终止
|
||||||
|
match process.try_wait() {
|
||||||
|
std::result::Result::Ok(Some(status)) => {
|
||||||
|
info!(
|
||||||
|
"Core process has already terminated with status: {:?}",
|
||||||
|
status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
std::result::Result::Ok(None) => {
|
||||||
|
info!("Core process is still running, attempting to terminate it.");
|
||||||
|
process
|
||||||
|
.kill()
|
||||||
|
.await
|
||||||
|
.context("Failed to kill core process")?;
|
||||||
|
info!("Core process killed successfully.");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to check core process state: {:?}", e);
|
||||||
|
bail!("Failed to check the status of the core process")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
*core_state = CoreState::Stopped;
|
||||||
|
info!("Core state updated to Stopped.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 重启核心
|
||||||
|
pub async fn restart_core(&self, config: CoreConfig) -> Result<()> {
|
||||||
|
info!("Stopping core before restart...");
|
||||||
|
self.stop_core().await?;
|
||||||
|
|
||||||
|
info!("Starting core after stop...");
|
||||||
|
self.start_core(config).await?;
|
||||||
|
|
||||||
|
info!("Core restarted successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src-tauri/system-service/src/core/mod.rs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
mod manager;
|
||||||
|
|
||||||
|
pub use manager::CoreManager;
|
||||||
196
src-tauri/system-service/src/installer/install.rs
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use paw_common::preclude::*;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
use std::ffi::{OsStr, OsString};
|
||||||
|
|
||||||
|
use windows_service::{
|
||||||
|
self,
|
||||||
|
service::{
|
||||||
|
ServiceAccess, ServiceErrorControl, ServiceInfo, ServiceStartType, ServiceState,
|
||||||
|
ServiceType,
|
||||||
|
},
|
||||||
|
service_manager::{ServiceManager, ServiceManagerAccess},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建Manager,所需权限为连接和创建服务(此处权限针对全部服务)
|
||||||
|
let manager_access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE;
|
||||||
|
let manager = ServiceManager::local_computer(None::<&str>, manager_access)?;
|
||||||
|
|
||||||
|
let service_path = get_bin_path(SERVICE_BIN_NAME)?;
|
||||||
|
|
||||||
|
println!("Installing service...");
|
||||||
|
// 先检测服务是否已经存在,如果存在就启动
|
||||||
|
let service_access = ServiceAccess::QUERY_STATUS | ServiceAccess::START;
|
||||||
|
if let Ok(service) = manager.open_service(SERVICE_NAME, service_access) {
|
||||||
|
if let Ok(status) = service.query_status() {
|
||||||
|
match status.current_state {
|
||||||
|
ServiceState::StopPending
|
||||||
|
| ServiceState::Stopped
|
||||||
|
| ServiceState::PausePending
|
||||||
|
| ServiceState::Paused => {
|
||||||
|
println!("Service exists, starting...");
|
||||||
|
service.start(&Vec::<&OsStr>::new())?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Creating service...");
|
||||||
|
// 创建服务
|
||||||
|
let service_info = ServiceInfo {
|
||||||
|
name: OsString::from(SERVICE_NAME),
|
||||||
|
display_name: OsString::from(SERVICE_DISPLAY_NAME),
|
||||||
|
service_type: ServiceType::OWN_PROCESS,
|
||||||
|
start_type: ServiceStartType::AutoStart, // 服务需要全程运行,因此设为自动启动
|
||||||
|
error_control: ServiceErrorControl::Normal,
|
||||||
|
executable_path: service_path,
|
||||||
|
launch_arguments: vec![],
|
||||||
|
dependencies: vec![],
|
||||||
|
account_name: None, // 文档说`use None to run as LocalSystem.`,为了获取权限,这里设为None
|
||||||
|
account_password: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 操作特定服务(也就是我们的Paw服务)所需要的权限,与 manager_access 是不同的
|
||||||
|
let service_access = ServiceAccess::CHANGE_CONFIG | ServiceAccess::START;
|
||||||
|
let service = manager.create_service(&service_info, service_access)?;
|
||||||
|
let args = Vec::<&str>::new();
|
||||||
|
service.set_description(SERVICE_DESCRIPTION)?;
|
||||||
|
service.start(&args)?;
|
||||||
|
println!("Service installed successfully.");
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
use std::{fs::File, io::Write, path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail};
|
||||||
|
|
||||||
|
println!("Installing service...");
|
||||||
|
let service_path = get_bin_path(SERVICE_BIN_NAME)?;
|
||||||
|
let output = Command::new("systemctl")
|
||||||
|
.args(["status", &format!("{}.service", SERVICE_NAME)])
|
||||||
|
.output()?;
|
||||||
|
|
||||||
|
// 检测服务是否已经存在,如果存在就启动,否则走下面的流程
|
||||||
|
// 代码参考 https://www.freedesktop.org/software/systemd/man/latest/systemctl.html#Exit%20status
|
||||||
|
match output.status.code() {
|
||||||
|
Some(0) => {
|
||||||
|
println!("Service is already running.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Some(1) | Some(2) | Some(3) => {
|
||||||
|
println!("Service is not running. Trying to start...");
|
||||||
|
Command::new("systemctl")
|
||||||
|
.args(["start", &format!("{}.service", SERVICE_NAME)])
|
||||||
|
.spawn()?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Some(4) => {}
|
||||||
|
code => bail!("Unexpected systemctl code:{:?}", code),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建服务
|
||||||
|
println!("Creating service...");
|
||||||
|
let unit = PathBuf::from("/etc/systemd/system/").join(format!("{}.service", SERVICE_NAME));
|
||||||
|
let content = format!(
|
||||||
|
// 路径是相对于当前文件的
|
||||||
|
include_str!("../template/systemctl.txt"),
|
||||||
|
service_path
|
||||||
|
.to_str()
|
||||||
|
.ok_or(anyhow!("Invalid service path"))?
|
||||||
|
);
|
||||||
|
|
||||||
|
File::create(unit)?.write_all(content.as_bytes())?;
|
||||||
|
|
||||||
|
println!("Reloading systemd...");
|
||||||
|
Command::new("systemctl").args(["daemon-reload"]).spawn()?;
|
||||||
|
Command::new("systemctl")
|
||||||
|
.args(["enable", &format!("{}.service", SERVICE_NAME)])
|
||||||
|
.spawn()?;
|
||||||
|
Command::new("systemctl")
|
||||||
|
.args(["start", &format!("{}.service", SERVICE_NAME)])
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
println!("Service installed successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
// 注意,macos的服务名称是 SERVICE_MACOS_NAME 而非 SERVICE_NAME
|
||||||
|
use std::{fs::File, io::Write, path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
println!("Installing service...");
|
||||||
|
let service_bin_path = get_bin_path(SERVICE_BIN_NAME)?; // 原可执行二进制路径
|
||||||
|
|
||||||
|
// 目标路径:可执行文件和 .plist 文件的标准位置
|
||||||
|
let plist_path = PathBuf::from(format!(
|
||||||
|
"/Library/LaunchDaemons/{}.plist",
|
||||||
|
SERVICE_MACOS_NAME
|
||||||
|
));
|
||||||
|
|
||||||
|
// 检查 .plist 文件是否已经存在,如果存在先卸载
|
||||||
|
if plist_path.exists() {
|
||||||
|
println!("Service is already installed. Attempting to unload and reload...");
|
||||||
|
if let Err(err) = Command::new("launchctl")
|
||||||
|
.arg("bootout")
|
||||||
|
.arg("system")
|
||||||
|
.arg(&plist_path)
|
||||||
|
.status()
|
||||||
|
{
|
||||||
|
eprintln!("Failed to unload service: {err:?}");
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 .plist 文件内容
|
||||||
|
let plist_content = format!(
|
||||||
|
include_str!("../template/helper.plist"),
|
||||||
|
SERVICE_MACOS_NAME,
|
||||||
|
service_bin_path.display().to_string(),
|
||||||
|
SERVICE_MACOS_NAME,
|
||||||
|
SERVICE_MACOS_NAME
|
||||||
|
);
|
||||||
|
|
||||||
|
// 写入 .plist 文件
|
||||||
|
println!("Writing plist file...");
|
||||||
|
let mut plist_file = File::create(&plist_path)?;
|
||||||
|
plist_file.write_all(plist_content.as_bytes())?;
|
||||||
|
|
||||||
|
// 修改权限为 root:wheel,并设置 644 权限
|
||||||
|
Command::new("chown")
|
||||||
|
.arg("root:wheel")
|
||||||
|
.arg(&plist_path)
|
||||||
|
.status()?;
|
||||||
|
Command::new("chmod").arg("644").arg(&plist_path).status()?;
|
||||||
|
|
||||||
|
// 加载服务到 launchd
|
||||||
|
println!("Loading service...");
|
||||||
|
Command::new("launchctl")
|
||||||
|
.arg("bootstrap")
|
||||||
|
.arg("system")
|
||||||
|
.arg(&plist_path)
|
||||||
|
.status()?;
|
||||||
|
|
||||||
|
// 启用服务
|
||||||
|
Command::new("launchctl")
|
||||||
|
.arg("enable")
|
||||||
|
.arg(format!("system/{}", SERVICE_MACOS_NAME))
|
||||||
|
.status()?;
|
||||||
|
// 启动服务
|
||||||
|
Command::new("launchctl")
|
||||||
|
.arg("start")
|
||||||
|
.arg(format!("system/{}", SERVICE_MACOS_NAME))
|
||||||
|
.status()?;
|
||||||
|
|
||||||
|
println!("Service installed successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
91
src-tauri/system-service/src/installer/uninstall.rs
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use paw_common::preclude::*;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
use windows_service::{
|
||||||
|
service::{ServiceAccess, ServiceState},
|
||||||
|
service_manager::{ServiceManager, ServiceManagerAccess},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建Manager,所需权限为连接和创建服务(此处权限针对全部服务)
|
||||||
|
let manager_access = ServiceManagerAccess::CONNECT;
|
||||||
|
let manager = ServiceManager::local_computer(None::<&str>, manager_access)?;
|
||||||
|
|
||||||
|
println!("Uninstalling service...");
|
||||||
|
let service_access = ServiceAccess::DELETE | ServiceAccess::STOP | ServiceAccess::QUERY_STATUS;
|
||||||
|
let service = manager.open_service(SERVICE_NAME, service_access)?;
|
||||||
|
|
||||||
|
if service.query_status()?.current_state != ServiceState::Stopped {
|
||||||
|
println!("Service is not stopped. Trying to stop...");
|
||||||
|
if let Err(e) = service.stop() {
|
||||||
|
eprintln!("Stop service failed: {e}");
|
||||||
|
}
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
service.delete()?;
|
||||||
|
println!("Service uninstalled successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
use std::{fs::remove_file, path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
println!("Uninstalling service...");
|
||||||
|
// 停止服务
|
||||||
|
Command::new("systemctl")
|
||||||
|
.args(&["stop", &format!("{}.service", SERVICE_NAME)])
|
||||||
|
.spawn()?;
|
||||||
|
Command::new("systemctl")
|
||||||
|
.args(&["disable", &format!("{}.service", SERVICE_NAME)])
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let unit = PathBuf::from("/etc/systemd/system/").join(format!("{}.service", SERVICE_NAME));
|
||||||
|
if unit.exists() {
|
||||||
|
println!("Removing service unit file...");
|
||||||
|
remove_file(unit)?;
|
||||||
|
}
|
||||||
|
Command::new("systemctl").args(&["daemon-reload"]).spawn()?;
|
||||||
|
|
||||||
|
println!("Service uninstalled successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn main() -> Result<()> {
|
||||||
|
use std::{fs, path::PathBuf, process::Command};
|
||||||
|
|
||||||
|
println!("Uninstalling service...");
|
||||||
|
|
||||||
|
// 服务相关路径
|
||||||
|
let plist_path = PathBuf::from(format!(
|
||||||
|
"/Library/LaunchDaemons/{}.plist",
|
||||||
|
SERVICE_MACOS_NAME
|
||||||
|
));
|
||||||
|
|
||||||
|
// 停止并卸载服务(如果已安装)
|
||||||
|
if plist_path.exists() {
|
||||||
|
println!("Stopping and removing service from launchd...");
|
||||||
|
Command::new("launchctl")
|
||||||
|
.arg("bootout")
|
||||||
|
.arg("system")
|
||||||
|
.arg(&plist_path)
|
||||||
|
.status()
|
||||||
|
.map_err(|e| {
|
||||||
|
eprintln!("Failed to stop service with launchctl: {e:?}");
|
||||||
|
e
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除 plist 文件
|
||||||
|
if plist_path.exists() {
|
||||||
|
println!("Removing plist file...");
|
||||||
|
fs::remove_file(&plist_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Service uninstalled successfully.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
168
src-tauri/system-service/src/main.rs
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
mod api;
|
||||||
|
mod core;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use tokio::{net::TcpListener, runtime::Runtime, sync::mpsc};
|
||||||
|
use paw_common::preclude::*;
|
||||||
|
|
||||||
|
use tracing::error;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use windows_service::{
|
||||||
|
define_windows_service, service_dispatcher,
|
||||||
|
service::{
|
||||||
|
ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus,
|
||||||
|
ServiceType,
|
||||||
|
},
|
||||||
|
service_control_handler::ServiceControlHandlerResult,
|
||||||
|
};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::{ffi::OsString, time::Duration};
|
||||||
|
#[cfg(windows)]
|
||||||
|
define_windows_service!(ffi_service_main, win_service_main);
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn win_service_main(_args: Vec<OsString>) {
|
||||||
|
use windows_service::service_control_handler;
|
||||||
|
// 用于发送服务关闭的信号
|
||||||
|
let (shutdown_tx, shutdown_rx) = mpsc::channel(1);
|
||||||
|
// 更改服务状态
|
||||||
|
// safe: 下面两个指令如果执行失败,说明我们没有权限更改服务状态或者windows内部出现问题,没有办法做处理,所以panic,panic信息会被hooks记录到日志中
|
||||||
|
let status_handle = service_control_handler::register(
|
||||||
|
SERVICE_NAME,
|
||||||
|
move |event| -> ServiceControlHandlerResult {
|
||||||
|
match event {
|
||||||
|
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
|
||||||
|
ServiceControl::Stop => {
|
||||||
|
// 收到服务关闭的控制请求,发送关闭HTTP服务器的信号
|
||||||
|
// 不使用tokio::sync::oneshot的原因是该闭包不是FnOnce,而是FnMut,使用mpsc不意味着会有多个发送者
|
||||||
|
// safe: 这个函数只有在缓冲已满、接收器被drop的情况下才会发生错误,我们的缓冲只可能有一个元素(因为只有此处发送),接收器在main结束后才会drop
|
||||||
|
shutdown_tx.try_send(()).unwrap();
|
||||||
|
ServiceControlHandlerResult::NoError
|
||||||
|
}
|
||||||
|
_ => ServiceControlHandlerResult::NotImplemented,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
status_handle
|
||||||
|
.set_service_status(ServiceStatus {
|
||||||
|
service_type: ServiceType::OWN_PROCESS,
|
||||||
|
current_state: ServiceState::Running,
|
||||||
|
controls_accepted: ServiceControlAccept::STOP,
|
||||||
|
exit_code: ServiceExitCode::Win32(0),
|
||||||
|
checkpoint: 0,
|
||||||
|
wait_hint: Duration::default(),
|
||||||
|
process_id: None,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 再启动服务器
|
||||||
|
inner_main(shutdown_rx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 启动服务
|
||||||
|
/// shutdown_rx: 用于接收Win下服务关闭的信号,在收到服务关闭信号后关闭Axum服务器
|
||||||
|
fn inner_main(shutdown_rx: mpsc::Receiver<()>) {
|
||||||
|
use tracing_subscriber::{self, fmt};
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
let console_layer = fmt::layer().with_timer(fmt::time::LocalTime::rfc_3339());
|
||||||
|
// macOS 和 linux 只记录到标准输出流
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
tracing_subscriber::registry().with(console_layer).init();
|
||||||
|
}
|
||||||
|
// windows 记录到文件
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use tracing_appender::rolling::{RollingFileAppender, Rotation};
|
||||||
|
let file_appender = RollingFileAppender::builder()
|
||||||
|
.rotation(Rotation::DAILY)
|
||||||
|
.filename_prefix("paw-service")
|
||||||
|
.filename_suffix("log")
|
||||||
|
.max_log_files(3)
|
||||||
|
.build(
|
||||||
|
std::env::current_exe()
|
||||||
|
.expect("Failed to get executable path")
|
||||||
|
.parent()
|
||||||
|
.expect("Failed to get executable parent dir")
|
||||||
|
.join("logs"),
|
||||||
|
)
|
||||||
|
.expect("initializing rolling file appender failed");
|
||||||
|
|
||||||
|
let file_layer = fmt::layer()
|
||||||
|
.with_timer(fmt::time::LocalTime::rfc_3339())
|
||||||
|
.with_writer(file_appender)
|
||||||
|
.with_ansi(false);
|
||||||
|
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(tracing::level_filters::LevelFilter::INFO)
|
||||||
|
.with(console_layer)
|
||||||
|
.with(file_layer)
|
||||||
|
.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Starting PAW Service...");
|
||||||
|
|
||||||
|
std::panic::set_hook(Box::new(|info| {
|
||||||
|
let msg = info.to_string();
|
||||||
|
error!("{}", msg);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 启动服务器,由于主函数不能是异步的,所以需要启动一个 Tokio 运行时
|
||||||
|
let rt = Runtime::new().expect("Failed to create Tokio runtime");
|
||||||
|
rt.block_on(async {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/service/stop", post(api::stop_service))
|
||||||
|
.route("/service/version", get(api::get_service_version))
|
||||||
|
.route("/core/start", post(api::start_core))
|
||||||
|
.route("/core/stop", post(api::stop_core))
|
||||||
|
.route("/core/restart", post(api::restart_core));
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(format!("0.0.0.0:{}", SYSTEM_SERVICE_PORT))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal(shutdown_rx))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
info!("Shutting down PAW Service...");
|
||||||
|
match utils::close_core_and_service().await {
|
||||||
|
Ok(_) => info!("Service stopped successfully"),
|
||||||
|
Err(e) => error!("Error stopping service: {:?}", e),
|
||||||
|
}
|
||||||
|
info!("PAW Service stopped");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 控制Axum的HTTP服务器关闭的信号
|
||||||
|
/// shutdown_rx: 用于接收服务关闭的信号(仅在Win下使用)
|
||||||
|
/// ctrl_c: 用于接收Ctrl+C信号(仅在Linux和macOS下使用)
|
||||||
|
async fn shutdown_signal(mut shutdown_rx: mpsc::Receiver<()>) {
|
||||||
|
#[cfg(windows)]
|
||||||
|
shutdown_rx.recv().await;
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
{
|
||||||
|
use tokio::signal::unix::*;
|
||||||
|
signal(SignalKind::terminate()).unwrap().recv().await;
|
||||||
|
}
|
||||||
|
info!("Shutdown signal received, stopping service...");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn main() -> windows_service::Result<()> {
|
||||||
|
service_dispatcher::start(paw_common::preclude::SERVICE_NAME, ffi_service_main)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn main() {
|
||||||
|
inner_main(tokio::sync::mpsc::channel(1).1);
|
||||||
|
}
|
||||||
21
src-tauri/system-service/src/template/helper.plist
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "https://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>{}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>{}</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/var/log/{}.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/var/log/{}.err</string>
|
||||||
|
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
15
src-tauri/system-service/src/template/systemctl.txt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Paw service.
|
||||||
|
After=network-online.target nftables.service iptables.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart={}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
68
src-tauri/system-service/src/utils.rs
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
use paw_common::preclude::CoreResponse;
|
||||||
|
use crate::core::CoreManager;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
/// 响应结构体,主要是为了把错误信息统一处理成 json
|
||||||
|
pub trait IntoResponse<T> {
|
||||||
|
fn into_response(self) -> CoreResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Default, E: std::fmt::Debug> IntoResponse<T> for Result<T, E> {
|
||||||
|
fn into_response(self) -> CoreResponse<T> {
|
||||||
|
match self {
|
||||||
|
Ok(data) => CoreResponse::ok(data),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Error occurred: {:?}", e);
|
||||||
|
CoreResponse::err(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub async fn close_core_and_service() -> Result<(), windows_service::Error> {
|
||||||
|
use std::time::Duration;
|
||||||
|
use paw_common::preclude::SERVICE_NAME;
|
||||||
|
use windows_service::{
|
||||||
|
service::{
|
||||||
|
ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, ServiceType,
|
||||||
|
},
|
||||||
|
service_control_handler,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 先停止核心
|
||||||
|
match CoreManager::instance().stop_core().await {
|
||||||
|
Ok(_) => info!("Core stopped."),
|
||||||
|
Err(e) => error!("Failed to stop core: {:?}", e),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. 获取状态信息,终止服务
|
||||||
|
let status = service_control_handler::register(SERVICE_NAME, |_| {
|
||||||
|
service_control_handler::ServiceControlHandlerResult::NoError
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let result = status.set_service_status(ServiceStatus {
|
||||||
|
service_type: ServiceType::OWN_PROCESS,
|
||||||
|
current_state: ServiceState::Stopped,
|
||||||
|
controls_accepted: ServiceControlAccept::empty(),
|
||||||
|
exit_code: ServiceExitCode::Win32(0),
|
||||||
|
checkpoint: 0,
|
||||||
|
wait_hint: Duration::default(),
|
||||||
|
process_id: None,
|
||||||
|
});
|
||||||
|
info!("Service stopped: {:?}", result);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub async fn close_core_and_service() -> Result<(), String> {
|
||||||
|
// 停止核心
|
||||||
|
match CoreManager::instance().stop_core().await {
|
||||||
|
Ok(_) => info!("Core stopped."),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to stop core: {:?}", e);
|
||||||
|
return Err(e.to_string());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
69
src-tauri/tauri.conf.json
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "paw-gui",
|
||||||
|
"identifier": "com.paw.paw-gui",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm web:dev",
|
||||||
|
"devUrl": "http://127.0.0.1:1420",
|
||||||
|
"beforeBuildCommand": "pnpm web:build",
|
||||||
|
"frontendDist": "../dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "paw-gui",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"minWidth": 1280,
|
||||||
|
"minHeight": 700,
|
||||||
|
"maxWidth": 2560,
|
||||||
|
"maxHeight": 1440,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"assetProtocol": {
|
||||||
|
"enable": true,
|
||||||
|
"scope": {
|
||||||
|
"allow": ["**/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pattern": {
|
||||||
|
"use": "brownfield"
|
||||||
|
},
|
||||||
|
"csp": {
|
||||||
|
"default-src": "'self' asset: http://asset.localhost",
|
||||||
|
"connect-src": "'self' asset: http://asset.localhost blob: ipc: http://ipc.localhost https://api.github.com",
|
||||||
|
"img-src": "'self' asset: http://asset.localhost data:",
|
||||||
|
"media-src": "'self' asset: http://asset.localhost blob: data:",
|
||||||
|
"child-src": "'self'; object-src 'self'",
|
||||||
|
"font-src": "'self' data:",
|
||||||
|
"style-src": "'self' 'unsafe-inline'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"withGlobalTauri": true
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"resources": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/logo.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico",
|
||||||
|
"assets/video/crystal-ball.mp4"
|
||||||
|
],
|
||||||
|
"externalBin": [
|
||||||
|
"sidecar/paw-core",
|
||||||
|
"sidecar/paw-install-service",
|
||||||
|
"sidecar/paw-uninstall-service",
|
||||||
|
"sidecar/paw-system-service"
|
||||||
|
],
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src-tauri/tauri.linux.conf.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
|
"identifier": "com.paw.paw-gui",
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["deb", "rpm"],
|
||||||
|
"linux": {
|
||||||
|
"appimage": {
|
||||||
|
"bundleMediaFramework": true
|
||||||
|
},
|
||||||
|
"deb": {
|
||||||
|
"depends": ["openssl"],
|
||||||
|
"provides": ["paw-gui"],
|
||||||
|
"conflicts": ["paw-gui"],
|
||||||
|
"replaces": ["paw-gui"],
|
||||||
|
"postInstallScript": "./install-scripts/linux-postinstall.sh",
|
||||||
|
"preRemoveScript": "./install-scripts/linux-preremove.sh",
|
||||||
|
"desktopTemplate": "./install-scripts/linux-template.desktop"
|
||||||
|
},
|
||||||
|
"rpm": {
|
||||||
|
"depends": ["openssl"],
|
||||||
|
"provides": ["paw-gui"],
|
||||||
|
"conflicts": ["paw-gui"],
|
||||||
|
"obsoletes": ["paw-gui"],
|
||||||
|
"postInstallScript": "./install-scripts/linux-postinstall.sh",
|
||||||
|
"preRemoveScript": "./install-scripts/linux-preremove.sh",
|
||||||
|
"desktopTemplate": "./install-scripts/linux-template.desktop"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src-tauri/tauri.macos.conf.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
|
"identifier": "com.paw.paw-gui",
|
||||||
|
"app": {
|
||||||
|
"macOSPrivateApi": false,
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"url": "/",
|
||||||
|
"title": "paw-gui",
|
||||||
|
"width": 1420,
|
||||||
|
"height": 1000,
|
||||||
|
"minWidth": 1440,
|
||||||
|
"minHeight": 700,
|
||||||
|
"maxWidth": 2560,
|
||||||
|
"maxHeight": 1440,
|
||||||
|
"decorations": true,
|
||||||
|
"visible": true,
|
||||||
|
"center": true,
|
||||||
|
"hiddenTitle": true,
|
||||||
|
"transparent": true,
|
||||||
|
"titleBarStyle": "Overlay"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"targets": ["app", "dmg"],
|
||||||
|
"macOS": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src-tauri/tauri.windows.conf.json
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
|
"identifier": "com.paw.paw-gui",
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"url": "/",
|
||||||
|
"title": "paw-gui",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"minWidth": 1440,
|
||||||
|
"minHeight": 700,
|
||||||
|
"maxWidth": 2560,
|
||||||
|
"maxHeight": 1440,
|
||||||
|
"visible": true,
|
||||||
|
"hiddenTitle": true,
|
||||||
|
"decorations": false,
|
||||||
|
"center": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/logo.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"targets": ["nsis"],
|
||||||
|
"windows": {
|
||||||
|
"nsis": {
|
||||||
|
"installMode": "perMachine",
|
||||||
|
"installerHooks": "./install-scripts/win-hooks.nsi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/App.css
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 240 10% 3.9%;
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 240 10% 3.9%;
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 240 10% 3.9%;
|
||||||
|
--primary: 240 5.9% 10%;
|
||||||
|
--primary-foreground: 0 0% 98%;
|
||||||
|
--secondary: 240 4.8% 95.9%;
|
||||||
|
--secondary-foreground: 240 5.9% 10%;
|
||||||
|
--muted: 240 4.8% 95.9%;
|
||||||
|
--muted-foreground: 240 3.8% 46.1%;
|
||||||
|
--accent: 240 4.8% 95.9%;
|
||||||
|
--accent-foreground: 240 5.9% 10%;
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 5.9% 90%;
|
||||||
|
--input: 240 5.9% 90%;
|
||||||
|
--ring: 240 10% 3.9%;
|
||||||
|
--chart-1: 12 76% 61%;
|
||||||
|
--chart-2: 173 58% 39%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.dark {
|
||||||
|
--background: 240 10% 3.9%;
|
||||||
|
--foreground: 0 0% 98%;
|
||||||
|
--card: 240 10% 3.9%;
|
||||||
|
--card-foreground: 0 0% 98%;
|
||||||
|
--popover: 240 10% 3.9%;
|
||||||
|
--popover-foreground: 0 0% 98%;
|
||||||
|
--primary: 0 0% 98%;
|
||||||
|
--primary-foreground: 240 5.9% 10%;
|
||||||
|
--secondary: 240 3.7% 15.9%;
|
||||||
|
--secondary-foreground: 0 0% 98%;
|
||||||
|
--muted: 240 3.7% 15.9%;
|
||||||
|
--muted-foreground: 240 5% 64.9%;
|
||||||
|
--accent: 240 3.7% 15.9%;
|
||||||
|
--accent-foreground: 0 0% 98%;
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 0 0% 98%;
|
||||||
|
--border: 240 3.7% 15.9%;
|
||||||
|
--input: 240 3.7% 15.9%;
|
||||||
|
--ring: 240 4.9% 83.9%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-2: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-5: 340 75% 55%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/App.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useStartupCheck } from '@/hooks/useStartupCheck'
|
||||||
|
import { usePreventDefault } from '@/hooks/usePreventDefaultEventListener'
|
||||||
|
import { useGlobalShortcut } from '@/hooks/useGlobalShortcut'
|
||||||
|
import { useRecreateTheCircuit } from '@/hooks/useRecreateTheCircuit'
|
||||||
|
import { useCoreConfig } from './hooks/useCoreConfig'
|
||||||
|
// import { commands } from '@/bindings'
|
||||||
|
import Titlebar from '@/components/Titlebar'
|
||||||
|
|
||||||
|
import Layout from '@/layout'
|
||||||
|
import Tray from '@/components/Tray'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
// 执行启动自检
|
||||||
|
useStartupCheck()
|
||||||
|
// 阻止默认事件
|
||||||
|
usePreventDefault()
|
||||||
|
// 注册全局快捷方式
|
||||||
|
useGlobalShortcut()
|
||||||
|
// 当核心启动时重新创建电路
|
||||||
|
useRecreateTheCircuit()
|
||||||
|
// 读取配置,若文件不存在则持久化到本地 `%APPDATA%/com.paw.paw-gui`
|
||||||
|
const { loadCoreConfig } = useCoreConfig()
|
||||||
|
loadCoreConfig()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<Titlebar />
|
||||||
|
<Layout />
|
||||||
|
<Tray />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
0
src/api/home.ts
Normal file
0
src/api/index.ts
Normal file
32127
src/assets/echarts-map/json/world.json
Normal file
BIN
src/assets/gif/web3-box-bg.gif
Normal file
|
After Width: | Height: | Size: 2.3 MiB |
BIN
src/assets/image/Line.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
src/assets/image/bg.jpg
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src/assets/image/home/open-proxy.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
src/assets/image/home/web3-box.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
src/assets/image/home/web3-box2.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
src/assets/image/line-bg.png
Normal file
|
After Width: | Height: | Size: 4.9 MiB |
BIN
src/assets/image/nested-encryption.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
src/assets/image/res/flag/AD.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/image/res/flag/AE.png
Normal file
|
After Width: | Height: | Size: 945 B |
BIN
src/assets/image/res/flag/AF.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/image/res/flag/AG.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/assets/image/res/flag/AI.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/image/res/flag/AL.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/image/res/flag/AM.png
Normal file
|
After Width: | Height: | Size: 884 B |
BIN
src/assets/image/res/flag/AO.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/assets/image/res/flag/AR.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/image/res/flag/AS.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
src/assets/image/res/flag/AT.png
Normal file
|
After Width: | Height: | Size: 870 B |
BIN
src/assets/image/res/flag/AU.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
src/assets/image/res/flag/AW.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/assets/image/res/flag/AX.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/assets/image/res/flag/AZ.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |