Compare commits
15 Commits
7
.github/workflows/ci.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: CI
|
||||
name: Release CI
|
||||
|
||||
on: [push]
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
yarn run check
|
||||
|
||||
- name: Tauri build
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@b9ce5d7dc68082d21d30a60103b0ab8c5ddae3a1
|
||||
# enable cache even though failed
|
||||
continue-on-error: true
|
||||
env:
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
with:
|
||||
tagName: v__VERSION__
|
||||
releaseName: "Clash Verge v__VERSION__"
|
||||
releaseBody: "Clash Verge now supports Windows and macos(intel)."
|
||||
releaseBody: "Clash Verge now supports Windows and macos."
|
||||
releaseDraft: false
|
||||
prerelease: true
|
||||
|
||||
@@ -95,6 +95,5 @@ jobs:
|
||||
|
||||
- name: Release update.json
|
||||
run: yarn run release
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
10
README.md
@@ -11,19 +11,17 @@ A <a href="https://github.com/Dreamacro/clash">Clash</a> GUI based on <a href="h
|
||||
|
||||
## Features
|
||||
|
||||
Now it's no different from the others, even fewer. (WIP)
|
||||
Now it's no different from the others, maybe fewer. (WIP)
|
||||
|
||||
## Development
|
||||
|
||||
You should install Rust and Nodejs. Then install tauri cli and packages.
|
||||
You should install Rust and Nodejs, see [here](https://tauri.studio/docs/getting-started/prerequisites) for more details. Then install Nodejs packages.
|
||||
|
||||
```shell
|
||||
cargo install tauri-cli --git https://github.com/tauri-apps/tauri
|
||||
|
||||
yarn install
|
||||
```
|
||||
|
||||
Then download the clash binary... Or you can download it from [clash premium release](https://github.com/Dreamacro/clash/releases/tag/premium) and rename it according to [tauri config](https://tauri.studio/en/docs/api/config#tauri.bundle.externalBin).
|
||||
Then download the clash binary... Or you can download it from [clash premium release](https://github.com/Dreamacro/clash/releases/tag/premium) and rename it according to [tauri config](https://tauri.studio/docs/api/config/#tauri.bundle.externalBin).
|
||||
|
||||
```shell
|
||||
yarn run check
|
||||
@@ -56,7 +54,7 @@ This is a learning project for Rust practice.
|
||||
|
||||
## Contributions
|
||||
|
||||
PR welcome!
|
||||
Issue and PR welcome!
|
||||
|
||||
## Acknowledgement
|
||||
|
||||
|
||||
BIN
docs/demo1.png
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
BIN
docs/demo2.png
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 30 KiB |
BIN
docs/demo3.png
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
BIN
docs/demo4.png
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 24 KiB |
BIN
docs/demo5.png
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 27 KiB |
BIN
docs/demo6.png
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 28 KiB |
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "clash-verge",
|
||||
"version": "0.0.16",
|
||||
"version": "0.0.18",
|
||||
"license": "GPL-3.0",
|
||||
"scripts": {
|
||||
"dev": "cargo tauri dev",
|
||||
"build": "cargo tauri build",
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build",
|
||||
"tauri": "tauri",
|
||||
"web:dev": "vite",
|
||||
"web:build": "tsc && vite build",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createRequire } from "module";
|
||||
import fetch from "node-fetch";
|
||||
import { getOctokit, context } from "@actions/github";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const UPDATE_TAG_NAME = "updater";
|
||||
const UPDATE_JSON_FILE = "update.json";
|
||||
|
||||
/// generate update.json
|
||||
/// upload to update tag's release asset
|
||||
@@ -10,46 +11,85 @@ async function resolveRelease() {
|
||||
throw new Error("GITHUB_TOKEN is required");
|
||||
}
|
||||
|
||||
const packageJson = require("../package.json");
|
||||
const options = { owner: context.repo.owner, repo: context.repo.repo };
|
||||
const github = getOctokit(process.env.GITHUB_TOKEN);
|
||||
|
||||
const { data: tags } = await github.rest.repos.listTags({
|
||||
...options,
|
||||
per_page: 10,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
// get the latest publish tag
|
||||
const tag = tags.find((t) => t.name.startsWith("v"));
|
||||
|
||||
console.log(tag);
|
||||
console.log();
|
||||
|
||||
const { data: latestRelease } = await github.rest.repos.getReleaseByTag({
|
||||
...options,
|
||||
tag: tag.name,
|
||||
});
|
||||
|
||||
const { version } = packageJson;
|
||||
const urlPrefix = "https://github.com/zzzgydi/clash-verge/releases/download";
|
||||
const updateData = {
|
||||
name: `v${version}`,
|
||||
notes: `Version ${version} is available now!!!`,
|
||||
name: tag.name,
|
||||
notes: latestRelease.body, // use the release body directly
|
||||
pub_date: new Date().toISOString(),
|
||||
platforms: {
|
||||
win64: {
|
||||
signature: "",
|
||||
url: `${urlPrefix}/v${version}/clash-verge_${version}_x64.msi.zip`,
|
||||
},
|
||||
darwin: {
|
||||
signature: "",
|
||||
url: `${urlPrefix}/v${version}/clash-verge.app.tar.gz`,
|
||||
},
|
||||
win64: { signature: "", url: "" },
|
||||
darwin: { signature: "", url: "" },
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`Generating Version "${version}" update.json`);
|
||||
const promises = latestRelease.assets.map(async (asset) => {
|
||||
const { name, browser_download_url } = asset;
|
||||
|
||||
const github = getOctokit(process.env.GITHUB_TOKEN);
|
||||
|
||||
const { data: release } = await github.rest.repos.getReleaseByTag({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
tag: "updater",
|
||||
});
|
||||
const { data: assets } = await github.rest.repos.listReleaseAssets({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.id,
|
||||
// win64 url
|
||||
if (/\.msi\.zip$/.test(name)) {
|
||||
updateData.platforms.win64.url = browser_download_url;
|
||||
}
|
||||
// darwin url
|
||||
if (/\.app\.tar\.gz$/.test(name)) {
|
||||
updateData.platforms.darwin.url = browser_download_url;
|
||||
}
|
||||
// win64 signature
|
||||
if (/\.msi\.zip\.sig$/.test(name)) {
|
||||
updateData.platforms.win64.signature = await getSignature(
|
||||
browser_download_url
|
||||
);
|
||||
}
|
||||
// darwin signature
|
||||
if (/\.app\.tar\.gz\.sig$/.test(name)) {
|
||||
updateData.platforms.darwin.signature = await getSignature(
|
||||
browser_download_url
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
for (let asset of assets) {
|
||||
if (asset.name === "update.json") {
|
||||
await Promise.allSettled(promises);
|
||||
console.log(updateData);
|
||||
|
||||
// maybe should test the signature as well
|
||||
const { darwin, win64 } = updateData.platforms;
|
||||
if (!darwin.url) {
|
||||
console.log(`[Error]: failed to parse release for darwin`);
|
||||
delete updateData.platforms.darwin;
|
||||
}
|
||||
if (!win64.url) {
|
||||
console.log(`[Error]: failed to parse release for win64`);
|
||||
delete updateData.platforms.win64;
|
||||
}
|
||||
|
||||
// update the update.json
|
||||
const { data: updateRelease } = await github.rest.repos.getReleaseByTag({
|
||||
...options,
|
||||
tag: UPDATE_TAG_NAME,
|
||||
});
|
||||
|
||||
for (let asset of updateRelease.assets) {
|
||||
if (asset.name === UPDATE_JSON_FILE) {
|
||||
await github.rest.repos.deleteReleaseAsset({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
...options,
|
||||
asset_id: asset.id,
|
||||
});
|
||||
break;
|
||||
@@ -57,12 +97,21 @@ async function resolveRelease() {
|
||||
}
|
||||
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
release_id: release.id,
|
||||
name: "update.json",
|
||||
...options,
|
||||
release_id: updateRelease.id,
|
||||
name: UPDATE_JSON_FILE,
|
||||
data: JSON.stringify(updateData, null, 2),
|
||||
});
|
||||
}
|
||||
|
||||
resolveRelease();
|
||||
// get the signature file content
|
||||
async function getSignature(url) {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/octet-stream" },
|
||||
});
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
resolveRelease().catch(console.error);
|
||||
|
||||
7
src-tauri/Cargo.lock
generated
@@ -448,6 +448,7 @@ dependencies = [
|
||||
"auto-launch",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"dunce",
|
||||
"log",
|
||||
"log4rs",
|
||||
"port_scanner",
|
||||
@@ -868,6 +869,12 @@ dependencies = [
|
||||
"dtoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "453440c271cf5577fd2a40e4942540cb7d0d2f85e27c8d07dd0023c925a67541"
|
||||
|
||||
[[package]]
|
||||
name = "easy-parallel"
|
||||
version = "3.2.0"
|
||||
|
||||
@@ -14,6 +14,7 @@ tauri-build = { version = "1.0.0-rc.3", features = [] }
|
||||
|
||||
[dependencies]
|
||||
dirs = "4.0.0"
|
||||
dunce = "1.0.2"
|
||||
chrono = "0.4.19"
|
||||
serde_json = "1.0"
|
||||
serde_yaml = "0.8"
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
utils::{dirs::app_home_dir, fetch::fetch_profile, sysopt::SysProxyConfig},
|
||||
};
|
||||
use serde_yaml::Mapping;
|
||||
use std::process::Command;
|
||||
use std::{path::PathBuf, process::Command};
|
||||
use tauri::{api, State};
|
||||
|
||||
/// get all profiles from `profiles.yaml`
|
||||
@@ -175,10 +175,7 @@ pub fn view_profile(index: usize, profiles_state: State<'_, ProfilesState>) -> R
|
||||
};
|
||||
}
|
||||
|
||||
match open_command().arg(path).spawn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err("failed to open file by `open`".into()),
|
||||
}
|
||||
open_path_cmd(path, "failed to open file by `open`")
|
||||
}
|
||||
|
||||
/// restart the sidecar
|
||||
@@ -262,10 +259,26 @@ pub fn get_verge_config(verge_state: State<'_, VergeState>) -> Result<VergeConfi
|
||||
#[tauri::command]
|
||||
pub fn patch_verge_config(
|
||||
payload: VergeConfig,
|
||||
clash_state: State<'_, ClashState>,
|
||||
verge_state: State<'_, VergeState>,
|
||||
profiles_state: State<'_, ProfilesState>,
|
||||
) -> Result<(), String> {
|
||||
let tun_mode = payload.enable_tun_mode.clone();
|
||||
|
||||
let mut verge = verge_state.0.lock().unwrap();
|
||||
verge.patch_config(payload)
|
||||
verge.patch_config(payload)?;
|
||||
|
||||
// change tun mode
|
||||
if tun_mode.is_some() {
|
||||
let mut clash = clash_state.0.lock().unwrap();
|
||||
let profiles = profiles_state.0.lock().unwrap();
|
||||
|
||||
clash.tun_mode(tun_mode.unwrap())?;
|
||||
clash.update_config();
|
||||
profiles.activate(&clash)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// kill all sidecars when update app
|
||||
@@ -278,30 +291,35 @@ pub fn kill_sidecars() {
|
||||
#[tauri::command]
|
||||
pub fn open_app_dir() -> Result<(), String> {
|
||||
let app_dir = app_home_dir();
|
||||
|
||||
match open_command().arg(app_dir).spawn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err("failed to open logs dir".into()),
|
||||
}
|
||||
open_path_cmd(app_dir, "failed to open app dir")
|
||||
}
|
||||
|
||||
/// open logs dir
|
||||
#[tauri::command]
|
||||
pub fn open_logs_dir() -> Result<(), String> {
|
||||
let log_dir = app_home_dir().join("logs");
|
||||
|
||||
match open_command().arg(log_dir).spawn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err("failed to open logs dir".into()),
|
||||
}
|
||||
open_path_cmd(log_dir, "failed to open logs dir")
|
||||
}
|
||||
|
||||
/// get open/explorer command
|
||||
fn open_command() -> Command {
|
||||
let open = if cfg!(target_os = "windows") {
|
||||
"explorer"
|
||||
} else {
|
||||
"open"
|
||||
};
|
||||
Command::new(open)
|
||||
fn open_path_cmd(dir: PathBuf, err_str: &str) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
match Command::new("explorer")
|
||||
.creation_flags(0x08000000)
|
||||
.arg(dir)
|
||||
.spawn()
|
||||
{
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err(err_str.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
match Command::new("open").arg(dir).spawn() {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err(err_str.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,48 @@ impl Clash {
|
||||
}
|
||||
self.save_config()
|
||||
}
|
||||
|
||||
/// enable tun mode
|
||||
/// only revise the config and restart the
|
||||
pub fn tun_mode(&mut self, enable: bool) -> Result<(), String> {
|
||||
let tun_key = Value::String("tun".into());
|
||||
let tun_val = self.config.get(&tun_key);
|
||||
|
||||
let mut new_val = Mapping::new();
|
||||
|
||||
if tun_val.is_some() && tun_val.as_ref().unwrap().is_mapping() {
|
||||
new_val = tun_val.as_ref().unwrap().as_mapping().unwrap().clone();
|
||||
}
|
||||
|
||||
macro_rules! revise {
|
||||
($map: expr, $key: expr, $val: expr) => {
|
||||
let ret_key = Value::String($key.into());
|
||||
if $map.contains_key(&ret_key) {
|
||||
$map[&ret_key] = $val;
|
||||
} else {
|
||||
$map.insert(ret_key, $val);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! append {
|
||||
($map: expr, $key: expr, $val: expr) => {
|
||||
let ret_key = Value::String($key.into());
|
||||
if !$map.contains_key(&ret_key) {
|
||||
$map.insert(ret_key, $val);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
revise!(new_val, "enable", Value::from(enable));
|
||||
append!(new_val, "stack", Value::from("gvisor"));
|
||||
append!(new_val, "auto-route", Value::from(true));
|
||||
append!(new_val, "auto-detect-interface", Value::from(true));
|
||||
|
||||
revise!(self.config, "tun", Value::from(new_val));
|
||||
|
||||
self.save_config()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Clash {
|
||||
|
||||
@@ -329,7 +329,9 @@ pub async fn activate_profile(
|
||||
|
||||
// begin to generate the new profile config
|
||||
let def_config = config::read_yaml::<Mapping>(file_path.clone());
|
||||
let mut new_config = Mapping::new();
|
||||
|
||||
// use the clash config except 5 keys below
|
||||
let mut new_config = clash_config.clone();
|
||||
|
||||
// Only the following fields are allowed:
|
||||
// proxies/proxy-providers/proxy-groups/rule-providers/rules
|
||||
@@ -348,24 +350,6 @@ pub async fn activate_profile(
|
||||
}
|
||||
});
|
||||
|
||||
// add some of the clash `config.yaml` config to it
|
||||
let valid_keys = vec![
|
||||
"mixed-port",
|
||||
"log-level",
|
||||
"allow-lan",
|
||||
"external-controller",
|
||||
"secret",
|
||||
"mode",
|
||||
"ipv6",
|
||||
];
|
||||
valid_keys.iter().for_each(|key| {
|
||||
let key = Value::String(key.to_string());
|
||||
if clash_config.contains_key(&key) {
|
||||
let value = clash_config[&key].clone();
|
||||
new_config.insert(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
config::save_yaml(
|
||||
temp_path.clone(),
|
||||
&new_config,
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::{
|
||||
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use tauri::{api::path::resource_dir, async_runtime::Mutex};
|
||||
use tauri::{async_runtime::Mutex, utils::platform::current_exe};
|
||||
|
||||
/// ### `verge.yaml` schema
|
||||
#[derive(Default, Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -20,6 +20,9 @@ pub struct VergeConfig {
|
||||
/// enable traffic graph default is true
|
||||
pub traffic_graph: Option<bool>,
|
||||
|
||||
/// clash tun mode
|
||||
pub enable_tun_mode: Option<bool>,
|
||||
|
||||
/// can the app auto startup
|
||||
pub enable_auto_launch: Option<bool>,
|
||||
|
||||
@@ -122,13 +125,11 @@ impl Verge {
|
||||
}
|
||||
|
||||
/// init the auto launch
|
||||
pub fn init_launch(&mut self, package_info: &tauri::PackageInfo) {
|
||||
let app_name = "clash-verge";
|
||||
let app_path = get_app_path(app_name);
|
||||
let app_path = resource_dir(package_info, &tauri::Env::default())
|
||||
.unwrap()
|
||||
.join(app_path);
|
||||
let app_path = app_path.as_os_str().to_str().unwrap();
|
||||
pub fn init_launch(&mut self) {
|
||||
let app_exe = current_exe().unwrap();
|
||||
let app_exe = dunce::canonicalize(app_exe).unwrap();
|
||||
let app_name = app_exe.file_stem().unwrap().to_str().unwrap();
|
||||
let app_path = app_exe.as_os_str().to_str().unwrap();
|
||||
|
||||
let auto = AutoLaunchBuilder::new()
|
||||
.set_app_name(app_name)
|
||||
@@ -260,6 +261,11 @@ impl Verge {
|
||||
Verge::guard_proxy(self.guard_state.clone());
|
||||
}
|
||||
|
||||
// handle the tun mode
|
||||
if patch.enable_tun_mode.is_some() {
|
||||
self.config.enable_tun_mode = patch.enable_tun_mode;
|
||||
}
|
||||
|
||||
self.config.save_file()
|
||||
}
|
||||
}
|
||||
@@ -324,14 +330,3 @@ impl Verge {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Get the target app_path
|
||||
fn get_app_path(app_name: &str) -> String {
|
||||
#[cfg(target_os = "linux")]
|
||||
let ext = "";
|
||||
#[cfg(target_os = "macos")]
|
||||
let ext = "";
|
||||
#[cfg(target_os = "windows")]
|
||||
let ext = ".exe";
|
||||
String::from(app_name) + ext
|
||||
}
|
||||
|
||||
@@ -22,18 +22,27 @@ pub fn resolve_setup(app: &App) {
|
||||
let mut profiles = profiles_state.0.lock().unwrap();
|
||||
|
||||
if let Err(err) = clash.run_sidecar() {
|
||||
log::error!("{}", err);
|
||||
log::error!("{err}");
|
||||
}
|
||||
|
||||
*profiles = Profiles::read_file();
|
||||
if let Err(err) = profiles.activate(&clash) {
|
||||
log::error!("{}", err);
|
||||
log::error!("{err}");
|
||||
}
|
||||
|
||||
verge.init_sysproxy(clash.info.port.clone());
|
||||
verge.init_launch(app.package_info());
|
||||
// enable tun mode
|
||||
if verge.config.enable_tun_mode.clone().unwrap_or(false)
|
||||
&& verge.cur_sysproxy.is_some()
|
||||
&& verge.cur_sysproxy.as_ref().unwrap().enable
|
||||
{
|
||||
log::info!("enable tun mode");
|
||||
clash.tun_mode(true).unwrap();
|
||||
}
|
||||
|
||||
verge.init_launch();
|
||||
if let Err(err) = verge.sync_launch() {
|
||||
log::error!("{}", err);
|
||||
log::error!("{err}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"package": {
|
||||
"productName": "clash-verge",
|
||||
"version": "0.0.16"
|
||||
"version": "0.0.18"
|
||||
},
|
||||
"build": {
|
||||
"distDir": "../dist",
|
||||
|
||||
@@ -3,22 +3,37 @@ import { useEffect, useState } from "react";
|
||||
import { useRecoilValue } from "recoil";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { ArrowDownward, ArrowUpward } from "@mui/icons-material";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { ApiType } from "../../services/types";
|
||||
import { getInfomation } from "../../services/api";
|
||||
import { getVergeConfig } from "../../services/cmds";
|
||||
import { atomClashPort } from "../../states/setting";
|
||||
import parseTraffic from "../../utils/parse-traffic";
|
||||
import { atomClashPort } from "../../services/states";
|
||||
import useLogSetup from "./use-log-setup";
|
||||
import useTrafficGraph from "./use-traffic-graph";
|
||||
import parseTraffic from "../../utils/parse-traffic";
|
||||
|
||||
const LayoutTraffic = () => {
|
||||
const portValue = useRecoilValue(atomClashPort);
|
||||
const [traffic, setTraffic] = useState({ up: 0, down: 0 });
|
||||
const { canvasRef, appendData, toggleStyle } = useTrafficGraph();
|
||||
const [refresh, setRefresh] = useState({});
|
||||
|
||||
// whether hide traffic graph
|
||||
const { data } = useSWR("getVergeConfig", getVergeConfig);
|
||||
const trafficGraph = data?.traffic_graph ?? true;
|
||||
|
||||
// setup log ws during layout
|
||||
useLogSetup();
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: () => void = null!;
|
||||
|
||||
// should reconnect the traffic ws
|
||||
listen("restart_clash", () => setRefresh({})).then((fn) => (unlisten = fn));
|
||||
|
||||
return () => unlisten?.();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
@@ -34,7 +49,7 @@ const LayoutTraffic = () => {
|
||||
});
|
||||
|
||||
return () => ws?.close();
|
||||
}, [portValue]);
|
||||
}, [portValue, refresh]);
|
||||
|
||||
const [up, upUnit] = parseTraffic(traffic.up);
|
||||
const [down, downUnit] = parseTraffic(traffic.down);
|
||||
|
||||
49
src/components/layout/use-log-setup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect } from "react";
|
||||
import { useSetRecoilState } from "recoil";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { ApiType } from "../../services/types";
|
||||
import { getInfomation } from "../../services/api";
|
||||
import { atomLogData } from "../../services/states";
|
||||
|
||||
const MAX_LOG_NUM = 1000;
|
||||
|
||||
// setup the log websocket
|
||||
export default function useLogSetup() {
|
||||
const setLogData = useSetRecoilState(atomLogData);
|
||||
|
||||
useEffect(() => {
|
||||
let ws: WebSocket = null!;
|
||||
let unlisten: () => void = null!;
|
||||
|
||||
const handler = (event: MessageEvent<any>) => {
|
||||
const data = JSON.parse(event.data) as ApiType.LogItem;
|
||||
const time = dayjs().format("MM-DD HH:mm:ss");
|
||||
setLogData((l) => {
|
||||
if (l.length >= MAX_LOG_NUM) l.shift();
|
||||
return [...l, { ...data, time }];
|
||||
});
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const { server = "", secret = "" } = await getInfomation();
|
||||
|
||||
ws = new WebSocket(`ws://${server}/logs?token=${secret}`);
|
||||
ws.addEventListener("message", handler);
|
||||
|
||||
// reconnect the websocket
|
||||
unlisten = await listen("restart_clash", async () => {
|
||||
const { server = "", secret = "" } = await getInfomation();
|
||||
|
||||
ws?.close();
|
||||
ws = new WebSocket(`ws://${server}/logs?token=${secret}`);
|
||||
ws.addEventListener("message", handler);
|
||||
});
|
||||
})();
|
||||
|
||||
return () => {
|
||||
ws?.close();
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ interface Props<Value> {
|
||||
value?: Value;
|
||||
valueProps?: string;
|
||||
onChangeProps?: string;
|
||||
waitTime?: number;
|
||||
onChange?: (value: Value) => void;
|
||||
onFormat?: (...args: any[]) => Value;
|
||||
onGuard?: (value: Value, oldValue: Value) => Promise<void>;
|
||||
@@ -18,6 +19,7 @@ function GuardState<T>(props: Props<T>) {
|
||||
children,
|
||||
valueProps = "value",
|
||||
onChangeProps = "onChange",
|
||||
waitTime = 0, // debounce wait time default 0
|
||||
onGuard = noop,
|
||||
onCatch = noop,
|
||||
onChange = noop,
|
||||
@@ -25,34 +27,61 @@ function GuardState<T>(props: Props<T>) {
|
||||
} = props;
|
||||
|
||||
const lockRef = useRef(false);
|
||||
const saveRef = useRef(value);
|
||||
const lastRef = useRef(0);
|
||||
const timeRef = useRef<any>();
|
||||
|
||||
if (isValidElement(children)) {
|
||||
const childProps = { ...children.props };
|
||||
|
||||
childProps[valueProps] = value;
|
||||
childProps[onChangeProps] = async (...args: any[]) => {
|
||||
// 多次操作无效
|
||||
if (lockRef.current) return;
|
||||
|
||||
lockRef.current = true;
|
||||
const oldValue = value;
|
||||
|
||||
try {
|
||||
const newValue = (onFormat as any)(...args);
|
||||
// 先在ui上响应操作
|
||||
onChange(newValue);
|
||||
await onGuard(newValue, oldValue!);
|
||||
} catch (err: any) {
|
||||
// 状态回退
|
||||
onChange(oldValue!);
|
||||
onCatch(err);
|
||||
}
|
||||
lockRef.current = false;
|
||||
};
|
||||
return cloneElement(children, childProps);
|
||||
if (!isValidElement(children)) {
|
||||
return children as any;
|
||||
}
|
||||
|
||||
return children as any;
|
||||
const childProps = { ...children.props };
|
||||
|
||||
childProps[valueProps] = value;
|
||||
childProps[onChangeProps] = async (...args: any[]) => {
|
||||
// 多次操作无效
|
||||
if (lockRef.current) return;
|
||||
|
||||
lockRef.current = true;
|
||||
|
||||
try {
|
||||
const newValue = (onFormat as any)(...args);
|
||||
// 先在ui上响应操作
|
||||
onChange(newValue);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// save the old value
|
||||
if (waitTime <= 0 || now - lastRef.current >= waitTime) {
|
||||
saveRef.current = value;
|
||||
}
|
||||
|
||||
lastRef.current = now;
|
||||
|
||||
if (waitTime <= 0) {
|
||||
await onGuard(newValue, value!);
|
||||
} else {
|
||||
// debounce guard
|
||||
clearTimeout(timeRef.current);
|
||||
|
||||
timeRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await onGuard(newValue, saveRef.current!);
|
||||
} catch (err: any) {
|
||||
// 状态回退
|
||||
onChange(saveRef.current!);
|
||||
onCatch(err);
|
||||
}
|
||||
}, waitTime);
|
||||
}
|
||||
} catch (err: any) {
|
||||
// 状态回退
|
||||
onChange(saveRef.current!);
|
||||
onCatch(err);
|
||||
}
|
||||
lockRef.current = false;
|
||||
};
|
||||
return cloneElement(children, childProps);
|
||||
}
|
||||
|
||||
export default GuardState;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebounceFn } from "ahooks";
|
||||
import { useSetRecoilState } from "recoil";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useSetRecoilState } from "recoil";
|
||||
import {
|
||||
ListItemText,
|
||||
TextField,
|
||||
@@ -11,7 +9,7 @@ import {
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { ApiType } from "../../services/types";
|
||||
import { atomClashPort } from "../../states/setting";
|
||||
import { atomClashPort } from "../../services/states";
|
||||
import { patchClashConfig } from "../../services/cmds";
|
||||
import { SettingList, SettingItem } from "./setting";
|
||||
import { getClashConfig, getVersion, updateConfigs } from "../../services/api";
|
||||
@@ -25,18 +23,16 @@ interface Props {
|
||||
const SettingClash = ({ onError }: Props) => {
|
||||
const { mutate } = useSWRConfig();
|
||||
const { data: clashConfig } = useSWR("getClashConfig", getClashConfig);
|
||||
const { data: versionData } = useSWR("getVersion", getVersion);
|
||||
|
||||
const {
|
||||
ipv6 = false,
|
||||
"allow-lan": allowLan = false,
|
||||
"log-level": logLevel = "silent",
|
||||
"mixed-port": thePort = 0,
|
||||
"mixed-port": mixedPort = 0,
|
||||
} = clashConfig ?? {};
|
||||
|
||||
const setPort = useSetRecoilState(atomClashPort);
|
||||
const [mixedPort, setMixedPort] = useState(thePort);
|
||||
|
||||
useEffect(() => setMixedPort(thePort), [thePort]);
|
||||
const setGlobalClashPort = useSetRecoilState(atomClashPort);
|
||||
|
||||
const onSwitchFormat = (_e: any, value: boolean) => value;
|
||||
const onChangeData = (patch: Partial<ApiType.ConfigData>) => {
|
||||
@@ -47,35 +43,25 @@ const SettingClash = ({ onError }: Props) => {
|
||||
await patchClashConfig(patch);
|
||||
};
|
||||
|
||||
// restart clash when port is changed
|
||||
const { run: onUpdatePort } = useDebounceFn(
|
||||
async (port: number) => {
|
||||
try {
|
||||
if (port < 1000) {
|
||||
throw new Error("The port should not < 1000");
|
||||
}
|
||||
if (port > 65536) {
|
||||
throw new Error("The port should not > 65536");
|
||||
}
|
||||
await patchClashConfig({ "mixed-port": port });
|
||||
onChangeData({ "mixed-port": port });
|
||||
setPort(port);
|
||||
Notice.success("Change Clash port successfully!");
|
||||
} catch (err: any) {
|
||||
setMixedPort(thePort); // back to old port value
|
||||
Notice.error(err.message ?? err.toString());
|
||||
}
|
||||
},
|
||||
{ wait: 1000 }
|
||||
);
|
||||
const onUpdatePort = async (port: number) => {
|
||||
if (port < 1000) {
|
||||
throw new Error("The port should not < 1000");
|
||||
}
|
||||
if (port > 65536) {
|
||||
throw new Error("The port should not > 65536");
|
||||
}
|
||||
await patchClashConfig({ "mixed-port": port });
|
||||
setGlobalClashPort(port);
|
||||
Notice.success("Change Clash port successfully!");
|
||||
|
||||
// update the config
|
||||
mutate("getClashConfig");
|
||||
};
|
||||
|
||||
// get clash core version
|
||||
const [clashVer, setClashVer] = useState("");
|
||||
useEffect(() => {
|
||||
getVersion().then(({ premium, version }) => {
|
||||
setClashVer(premium ? `${version} Premium` : version);
|
||||
});
|
||||
}, []);
|
||||
const clashVer = versionData?.premium
|
||||
? `${versionData.version} Premium`
|
||||
: versionData?.version || "-";
|
||||
|
||||
return (
|
||||
<SettingList title="Clash Setting">
|
||||
@@ -130,9 +116,11 @@ const SettingClash = ({ onError }: Props) => {
|
||||
<ListItemText primary="Mixed Port" />
|
||||
<GuardState
|
||||
value={mixedPort!}
|
||||
onCatch={onError}
|
||||
onFormat={(e: any) => +e.target.value?.replace(/\D+/, "")}
|
||||
onChange={setMixedPort}
|
||||
onChange={(e) => onChangeData({ "mixed-port": e })}
|
||||
onGuard={onUpdatePort}
|
||||
waitTime={800}
|
||||
>
|
||||
<TextField autoComplete="off" size="small" sx={{ width: 120 }} />
|
||||
</GuardState>
|
||||
|
||||
@@ -15,6 +15,7 @@ const SettingSystem = ({ onError }: Props) => {
|
||||
const { data: vergeConfig } = useSWR("getVergeConfig", getVergeConfig);
|
||||
|
||||
const {
|
||||
enable_tun_mode = false,
|
||||
enable_auto_launch = false,
|
||||
enable_system_proxy = false,
|
||||
system_proxy_bypass = "",
|
||||
@@ -28,6 +29,20 @@ const SettingSystem = ({ onError }: Props) => {
|
||||
|
||||
return (
|
||||
<SettingList title="System Setting">
|
||||
<SettingItem>
|
||||
<ListItemText primary="Tun Mode" />
|
||||
<GuardState
|
||||
value={enable_tun_mode}
|
||||
valueProps="checked"
|
||||
onCatch={onError}
|
||||
onFormat={onSwitchFormat}
|
||||
onChange={(e) => onChangeData({ enable_tun_mode: e })}
|
||||
onGuard={(e) => patchVergeConfig({ enable_tun_mode: e })}
|
||||
>
|
||||
<Switch edge="end" />
|
||||
</GuardState>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem>
|
||||
<ListItemText primary="Auto Launch" />
|
||||
<GuardState
|
||||
@@ -91,6 +106,7 @@ const SettingSystem = ({ onError }: Props) => {
|
||||
onFormat={(e: any) => e.target.value}
|
||||
onChange={(e) => onChangeData({ system_proxy_bypass: e })}
|
||||
onGuard={(e) => patchVergeConfig({ system_proxy_bypass: e })}
|
||||
waitTime={1000}
|
||||
>
|
||||
<TextField autoComplete="off" size="small" sx={{ width: 120 }} />
|
||||
</GuardState>
|
||||
|
||||
@@ -1,38 +1,12 @@
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { Button, Paper } from "@mui/material";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { ApiType } from "../services/types";
|
||||
import { getInfomation } from "../services/api";
|
||||
import { atomLogData } from "../services/states";
|
||||
import BasePage from "../components/base/base-page";
|
||||
import LogItem from "../components/log/log-item";
|
||||
|
||||
let logCache: ApiType.LogItem[] = [];
|
||||
|
||||
const LogPage = () => {
|
||||
const [logData, setLogData] = useState(logCache);
|
||||
|
||||
useEffect(() => {
|
||||
let ws: WebSocket | null = null;
|
||||
|
||||
getInfomation().then((result) => {
|
||||
const { server = "", secret = "" } = result;
|
||||
ws = new WebSocket(`ws://${server}/logs?token=${secret}`);
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
const data = JSON.parse(event.data) as ApiType.LogItem;
|
||||
const time = dayjs().format("MM-DD HH:mm:ss");
|
||||
setLogData((l) => (logCache = [...l, { ...data, time }]));
|
||||
});
|
||||
});
|
||||
|
||||
return () => ws?.close();
|
||||
}, []);
|
||||
|
||||
const onClear = () => {
|
||||
setLogData([]);
|
||||
logCache = [];
|
||||
};
|
||||
const [logData, setLogData] = useRecoilState(atomLogData);
|
||||
|
||||
return (
|
||||
<BasePage
|
||||
@@ -43,7 +17,7 @@ const LogPage = () => {
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
variant="contained"
|
||||
onClick={onClear}
|
||||
onClick={() => setLogData([])}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
|
||||
12
src/services/states.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { atom } from "recoil";
|
||||
import { ApiType } from "./types";
|
||||
|
||||
export const atomClashPort = atom<number>({
|
||||
key: "atomClashPort",
|
||||
default: 0,
|
||||
});
|
||||
|
||||
export const atomLogData = atom<ApiType.LogItem[]>({
|
||||
key: "atomLogData",
|
||||
default: [],
|
||||
});
|
||||
@@ -113,6 +113,7 @@ export namespace CmdType {
|
||||
theme_mode?: "light" | "dark";
|
||||
theme_blur?: boolean;
|
||||
traffic_graph?: boolean;
|
||||
enable_tun_mode?: boolean;
|
||||
enable_auto_launch?: boolean;
|
||||
enable_system_proxy?: boolean;
|
||||
enable_proxy_guard?: boolean;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { atom } from "recoil";
|
||||
|
||||
export const atomClashPort = atom<number>({
|
||||
key: "atomClashPort",
|
||||
default: 0,
|
||||
});
|
||||