26 Commits

Author SHA1 Message Date
GyDi
b5af234524 v0.0.11 2022-01-17 02:53:07 +08:00
GyDi
b5c41750f7 fix: remove cargo test 2022-01-17 02:50:19 +08:00
GyDi
6083824eec v0.0.11 2022-01-17 02:44:08 +08:00
GyDi
40977785c3 feat: adjust setting typography 2022-01-17 02:42:52 +08:00
GyDi
5eddf4f1aa feat: enable force select profile 2022-01-17 02:28:23 +08:00
GyDi
99a8e25411 feat: support edit profile item 2022-01-17 02:16:17 +08:00
GyDi
08587d8f2f fix: reduce proxy item height 2022-01-17 02:15:54 +08:00
GyDi
3480d50f61 feat: adjust control ui 2022-01-17 02:15:06 +08:00
GyDi
43af55252d feat: update profile supports noproxy 2022-01-16 22:57:42 +08:00
GyDi
9c43b31fc0 refactor: something 2022-01-16 18:30:25 +08:00
GyDi
9ec7184aa1 fix: put profile request with no proxy 2022-01-16 18:20:01 +08:00
GyDi
4e2cb30db7 refactor: notice caller 2022-01-16 17:56:43 +08:00
GyDi
9ca83d3291 chore: change ci 2022-01-16 16:03:53 +08:00
GyDi
6b2172d873 fix: ci strategy 2022-01-16 14:30:49 +08:00
GyDi
1a5d9f7dad chore: enhance ci 2022-01-16 14:27:41 +08:00
GyDi
a8425862f0 feat: rename page 2022-01-16 03:25:50 +08:00
GyDi
a3a3db6abb refactor: setting page 2022-01-16 03:22:37 +08:00
GyDi
d6c3bc57c0 feat: refactor and adjust ui 2022-01-16 03:11:07 +08:00
GyDi
59c09f90f9 feat: rm some commands 2022-01-16 03:09:36 +08:00
GyDi
d982b83e14 feat: change type 2022-01-15 21:58:13 +08:00
GyDi
cc0e930d34 feat: supports auto launch on macos and windows 2022-01-15 21:55:05 +08:00
GyDi
3c3d77fbea chore: add auto-launch 2022-01-15 21:00:19 +08:00
GyDi
da7453fdbf v0.0.10 2022-01-13 02:53:29 +08:00
GyDi
5fcd25506e feat: adjust proxy page 2022-01-13 02:51:30 +08:00
GyDi
4979a472de feat: press esc hide the window 2022-01-13 02:11:50 +08:00
GyDi
4e8d4f4591 fix: version update error 2022-01-12 22:19:44 +08:00
44 changed files with 875 additions and 693 deletions

View File

@@ -1,26 +1,24 @@
name: Release Project
name: CI
on:
push:
tags:
- v*
on: [push]
env:
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: short
jobs:
build-tauri:
release:
strategy:
fail-fast: false
matrix:
platform: [windows-latest, macos-latest]
runs-on: ${{ matrix.platform }}
os: [windows-latest, macos-11]
runs-on: ${{ matrix.os }}
if: |
startsWith(github.repository, 'zzzgydi') &&
startsWith(github.ref, 'refs/tags/v')
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: 14
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
@@ -28,6 +26,14 @@ jobs:
profile: minimal
override: true
- name: Rust Cache
uses: Swatinem/rust-cache@ce325b60658c1b38465c06cc965b79baf32c1e72
- name: Install Node
uses: actions/setup-node@v1
with:
node-version: 14
- name: Get yarn cache dir path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn cache dir)"
@@ -41,14 +47,13 @@ jobs:
restore-keys: |
${{ runner.os }}-yarn-
- name: Rust Cache
uses: Swatinem/rust-cache@ce325b60658c1b38465c06cc965b79baf32c1e72
- name: Yarn install and check
run: yarn && yarn run check
- name: Tauri build
uses: tauri-apps/tauri-action@v0
# enable cache even though failed
continue-on-error: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@@ -1,6 +1,6 @@
{
"name": "clash-verge",
"version": "0.0.9",
"version": "0.0.11",
"license": "GPL-3.0",
"scripts": {
"dev": "cargo tauri dev",

View File

@@ -11,10 +11,16 @@ async function resolvePublish() {
let [a, b, c] = packageJson.version.split(".").map(Number);
if (flag === "major") a += 1;
else if (flag === "minor") b += 1;
else if (flag === "patch") c += 1;
else throw new Error(`invalid flag "${flag}"`);
if (flag === "major") {
a += 1;
b = 0;
c = 0;
} else if (flag === "minor") {
b += 1;
c = 0;
} else if (flag === "patch") {
c += 1;
} else throw new Error(`invalid flag "${flag}"`);
const nextVersion = `${a}.${b}.${c}`;
packageJson.version = nextVersion;

23
src-tauri/Cargo.lock generated
View File

@@ -42,6 +42,7 @@ checksum = "84450d0b4a8bd1ba4144ce8ce718fbc5d071358b1e5384bace6536b3d1f2d5b3"
name = "app"
version = "0.1.0"
dependencies = [
"auto-launch",
"chrono",
"dirs 4.0.0",
"log",
@@ -57,6 +58,7 @@ dependencies = [
"tauri-plugin-vibrancy",
"tokio",
"warp",
"which",
"winreg 0.10.1",
]
@@ -145,6 +147,16 @@ dependencies = [
"wildmatch",
]
[[package]]
name = "auto-launch"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f0ab475f9b1049a9d4e9c83f45ff27ec3bce4a6cb8e58e7f29030f8992af8c5"
dependencies = [
"dirs 4.0.0",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.0.1"
@@ -4099,6 +4111,17 @@ dependencies = [
"cc",
]
[[package]]
name = "which"
version = "4.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea187a8ef279bc014ec368c27a920da2024d2a711109bfbe3440585d5cf27ad9"
dependencies = [
"either",
"lazy_static",
"libc",
]
[[package]]
name = "wildmatch"
version = "2.1.0"

View File

@@ -29,6 +29,8 @@ tokio = { version = "1", features = ["full"] }
log = "0.4.14"
log4rs = "1.0.0"
warp = "0.3"
which = "4.2.2"
auto-launch = "0.2"
port_scanner = "0.1.5"
[target.'cfg(windows)'.dependencies]

View File

@@ -3,12 +3,14 @@ use crate::{
states::{ClashState, ProfilesState, VergeState},
utils::{
config::{read_clash, save_clash},
dirs::app_home_dir,
fetch::fetch_profile,
sysopt::SysProxyConfig,
},
};
use serde_yaml::Mapping;
use tauri::{AppHandle, Manager, State};
use std::process::Command;
use tauri::State;
/// get all profiles from `profiles.yaml`
/// do not acquire the lock of ProfileLock
@@ -29,11 +31,15 @@ pub fn sync_profiles(profiles: State<'_, ProfilesState>) -> Result<(), String> {
}
}
/// Import the profile from url
/// import the profile from url
/// and save to `profiles.yaml`
#[tauri::command]
pub async fn import_profile(url: String, profiles: State<'_, ProfilesState>) -> Result<(), String> {
match fetch_profile(&url).await {
pub async fn import_profile(
url: String,
with_proxy: bool,
profiles: State<'_, ProfilesState>,
) -> Result<(), String> {
match fetch_profile(&url, with_proxy).await {
Some(result) => {
let mut profiles = profiles.0.lock().unwrap();
profiles.import_from_url(url, result)
@@ -43,12 +49,10 @@ pub async fn import_profile(url: String, profiles: State<'_, ProfilesState>) ->
}
/// Update the profile
/// and save to `profiles.yaml`
/// http request firstly
/// then acquire the lock of `profiles.yaml`
#[tauri::command]
pub async fn update_profile(
index: usize,
with_proxy: bool,
clash: State<'_, ClashState>,
profiles: State<'_, ProfilesState>,
) -> Result<(), String> {
@@ -69,7 +73,7 @@ pub async fn update_profile(
Err(_) => return Err("failed to get profiles lock".into()),
};
match fetch_profile(&url).await {
match fetch_profile(&url, with_proxy).await {
Some(result) => match profiles.0.lock() {
Ok(mut profiles) => {
profiles.update_item(index, result)?;
@@ -140,6 +144,34 @@ pub fn patch_profile(
}
}
/// run vscode command to edit the profile
#[tauri::command]
pub fn edit_profile(index: usize, profiles_state: State<'_, ProfilesState>) -> Result<(), String> {
let mut profiles = profiles_state.0.lock().unwrap();
let items = profiles.items.take().unwrap_or(vec![]);
if index >= items.len() {
profiles.items = Some(items);
return Err("the index out of bound".into());
}
let file = items[index].file.clone().unwrap_or("".into());
profiles.items = Some(items);
let path = app_home_dir().join("profiles").join(file);
if !path.exists() {
return Err("failed to open the file".into());
}
match which::which("code") {
Ok(code) => match Command::new(code).arg(path).status() {
Ok(_) => Ok(()),
Err(_) => Err("failed to open file by VScode".into()),
},
Err(_) => Err("please install VScode for edit".into()),
}
}
/// restart the sidecar
#[tauri::command]
pub fn restart_sidecar(
@@ -231,25 +263,3 @@ pub async fn patch_verge_config(
let mut verge = verge_state.0.lock().unwrap();
verge.patch_config(payload)
}
/// start dragging window
#[tauri::command]
pub fn win_drag(app_handle: AppHandle) {
let window = app_handle.get_window("main").unwrap();
window.start_dragging().unwrap();
}
/// hide the window
#[tauri::command]
pub fn win_hide(app_handle: AppHandle) {
let window = app_handle.get_window("main").unwrap();
window.hide().unwrap();
}
/// mini the window
#[tauri::command]
pub fn win_mini(app_handle: AppHandle) {
let window = app_handle.get_window("main").unwrap();
// todo: these methods still has bug on Windows
window.minimize().unwrap();
}

View File

@@ -312,7 +312,10 @@ pub async fn activate_profile(profile_item: &ProfileItem, info: &ClashInfo) -> R
let mut data = HashMap::new();
data.insert("path", temp_path.as_os_str().to_str().unwrap());
let client = reqwest::Client::new();
let client = match reqwest::ClientBuilder::new().no_proxy().build() {
Ok(c) => c,
Err(_) => return Err("failed to create http::put".into()),
};
match client.put(server).headers(headers).json(&data).send().await {
Ok(_) => Ok(()),
Err(err) => Err(format!("request failed `{}`", err.to_string())),

View File

@@ -1,6 +1,6 @@
use crate::utils::{config, dirs, startup, sysopt::SysProxyConfig};
use crate::utils::{config, dirs, sysopt::SysProxyConfig};
use auto_launch::{AutoLaunch, AutoLaunchBuilder};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use tauri::api::path::resource_dir;
/// ### `verge.yaml` schema
@@ -14,7 +14,7 @@ pub struct VergeConfig {
pub theme_blur: Option<bool>,
/// can the app auto startup
pub enable_self_startup: Option<bool>,
pub enable_auto_launch: Option<bool>,
/// set system proxy
pub enable_system_proxy: Option<bool>,
@@ -49,7 +49,7 @@ pub struct Verge {
pub cur_sysproxy: Option<SysProxyConfig>,
pub exe_path: Option<PathBuf>,
pub auto_launch: Option<AutoLaunch>,
}
impl Default for Verge {
@@ -64,7 +64,7 @@ impl Verge {
config: VergeConfig::new(),
old_sysproxy: None,
cur_sysproxy: None,
exe_path: None,
auto_launch: None,
}
}
@@ -101,53 +101,61 @@ impl Verge {
}
}
/// set the exe_path
pub fn set_exe_path(&mut self, package_info: &tauri::PackageInfo) {
let exe = if cfg!(target_os = "windows") {
"clash-verge.exe"
} else {
"clash-verge"
};
let path = resource_dir(package_info).unwrap().join(exe);
self.exe_path = Some(path);
/// 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).unwrap().join(app_path);
let app_path = app_path.as_os_str().to_str().unwrap();
let auto = AutoLaunchBuilder::new()
.set_app_name(app_name)
.set_app_path(app_path)
.build();
self.auto_launch = Some(auto);
}
/// sync the startup when run the app
pub fn sync_startup(&self) -> Result<(), String> {
let enable = self.config.enable_self_startup.clone().unwrap_or(false);
pub fn sync_launch(&self) -> Result<(), String> {
let enable = self.config.enable_auto_launch.clone().unwrap_or(false);
if !enable {
return Ok(());
}
if self.exe_path.is_none() {
return Err("should init the exe_path first".into());
if self.auto_launch.is_none() {
return Err("should init the auto launch first".into());
}
let exe_path = self.exe_path.clone().unwrap();
match startup::get_startup(&exe_path) {
Ok(sys_enable) => {
if sys_enable || (!sys_enable && startup::set_startup(true, &exe_path).is_ok()) {
Ok(())
} else {
Err("failed to sync startup".into())
}
let auto_launch = self.auto_launch.clone().unwrap();
let is_enabled = auto_launch.is_enabled().unwrap_or(false);
if !is_enabled {
if let Err(_) = auto_launch.enable() {
return Err("failed to enable auto-launch".into());
}
Err(_) => Err("failed to get system startup info".into()),
}
Ok(())
}
/// update the startup
fn update_startup(&mut self, enable: bool) -> Result<(), String> {
let conf_enable = self.config.enable_self_startup.clone().unwrap_or(false);
fn update_launch(&mut self, enable: bool) -> Result<(), String> {
let conf_enable = self.config.enable_auto_launch.clone().unwrap_or(false);
if enable == conf_enable {
return Ok(());
}
if self.exe_path.is_none() {
return Err("should init the exe_path first".into());
}
let exe_path = self.exe_path.clone().unwrap();
match startup::set_startup(enable, &exe_path) {
let auto_launch = self.auto_launch.clone().unwrap();
let result = if enable {
auto_launch.enable()
} else {
auto_launch.disable()
};
match result {
Ok(_) => Ok(()),
Err(_) => Err("failed to set system startup info".into()),
}
@@ -166,10 +174,10 @@ impl Verge {
}
// should update system startup
if patch.enable_self_startup.is_some() {
let enable = patch.enable_self_startup.unwrap();
self.update_startup(enable)?;
self.config.enable_self_startup = Some(enable);
if patch.enable_auto_launch.is_some() {
let enable = patch.enable_auto_launch.unwrap();
self.update_launch(enable)?;
self.config.enable_auto_launch = Some(enable);
}
// should update system proxy
@@ -187,7 +195,7 @@ impl Verge {
}
// todo
// should update system proxt too
// should update system proxy too
if patch.system_proxy_bypass.is_some() {
self.config.system_proxy_bypass = patch.system_proxy_bypass;
}
@@ -195,3 +203,14 @@ impl Verge {
self.config.save_file()
}
}
// 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 = ".app";
#[cfg(target_os = "windows")]
let ext = ".exe";
String::from(app_name) + ext
}

View File

@@ -3,8 +3,6 @@
windows_subsystem = "windows"
)]
extern crate tauri;
mod cmds;
mod core;
mod states;
@@ -37,6 +35,7 @@ fn main() -> std::io::Result<()> {
SystemTrayEvent::MenuItemClick { id, .. } => match id.as_str() {
"open_window" => {
let window = app_handle.get_window("main").unwrap();
window.unminimize().unwrap();
window.show().unwrap();
window.set_focus().unwrap();
}
@@ -63,6 +62,7 @@ fn main() -> std::io::Result<()> {
},
SystemTrayEvent::LeftClick { .. } => {
let window = app_handle.get_window("main").unwrap();
window.unminimize().unwrap();
window.show().unwrap();
window.set_focus().unwrap();
}
@@ -73,9 +73,6 @@ fn main() -> std::io::Result<()> {
cmds::restart_sidecar,
cmds::get_sys_proxy,
cmds::get_cur_proxy,
cmds::win_drag,
cmds::win_hide,
cmds::win_mini,
// clash
cmds::get_clash_info,
cmds::patch_clash_config,
@@ -83,13 +80,14 @@ fn main() -> std::io::Result<()> {
cmds::get_verge_config,
cmds::patch_verge_config,
// profile
cmds::edit_profile,
cmds::patch_profile,
cmds::import_profile,
cmds::update_profile,
cmds::delete_profile,
cmds::select_profile,
cmds::patch_profile,
cmds::sync_profiles,
cmds::get_profiles,
cmds::sync_profiles,
])
.build(tauri::generate_context!())
.expect("error while running tauri application")

View File

@@ -23,11 +23,20 @@ fn parse_string<T: FromStr>(target: &str, key: &str) -> Option<T> {
}
/// fetch and parse the profile
pub async fn fetch_profile(url: &str) -> Option<ProfileResponse> {
let resp = match reqwest::get(url).await {
Ok(res) => res,
pub async fn fetch_profile(url: &str, with_proxy: bool) -> Option<ProfileResponse> {
let builder = reqwest::ClientBuilder::new();
let client = match with_proxy {
true => builder.build(),
false => builder.no_proxy().build(),
};
let resp = match client {
Ok(client) => match client.get(url).send().await {
Ok(res) => res,
Err(_) => return None,
},
Err(_) => return None,
};
let header = resp.headers();
// parse the Subscription Userinfo

View File

@@ -4,5 +4,4 @@ pub mod fetch;
pub mod init;
pub mod resolve;
pub mod server;
pub mod startup;
pub mod sysopt;

View File

@@ -9,6 +9,7 @@ pub fn resolve_setup(app: &App) {
let window = app.get_window("main").unwrap();
window.set_shadow(true);
// enable system blur
use tauri_plugin_vibrancy::Vibrancy;
#[cfg(target_os = "windows")]
window.apply_blur();
@@ -42,9 +43,9 @@ pub fn resolve_setup(app: &App) {
log::error!("{}", err);
}
verge.set_exe_path(app.package_info());
verge.init_sysproxy(clash.info.port.clone());
if let Err(err) = verge.sync_startup() {
verge.init_launch(app.package_info());
if let Err(err) = verge.sync_launch() {
log::error!("{}", err);
}
}

View File

@@ -1,74 +0,0 @@
use std::io;
use std::path::PathBuf;
static APP_KEY: &str = "ClashVerge";
/// get the startup value
/// whether as same as the exe_path
#[cfg(target_os = "windows")]
pub fn get_startup(exe_path: &PathBuf) -> io::Result<bool> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let cur_var = hkcu.open_subkey_with_flags(
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
KEY_READ,
)?;
match cur_var.get_value::<String, _>(APP_KEY) {
Ok(path) => {
let exe_path = exe_path.clone();
let exe_path = exe_path.as_os_str().to_str().unwrap();
Ok(path == exe_path)
}
Err(_) => Ok(false),
}
}
/// set the startup on windows
/// delete the reg key if disabled
#[cfg(target_os = "windows")]
pub fn set_startup(enable: bool, exe_path: &PathBuf) -> io::Result<()> {
use winreg::enums::*;
use winreg::RegKey;
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let cur_var = hkcu.open_subkey_with_flags(
"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
KEY_SET_VALUE,
)?;
match enable {
true => {
let exe_path = exe_path.clone();
let exe_path = exe_path.as_os_str().to_str().unwrap();
cur_var.set_value::<&str, _>(APP_KEY, &exe_path)
}
false => cur_var.delete_value(APP_KEY),
}
}
/// todo
#[cfg(target_os = "macos")]
pub fn get_startup(exe_path: &PathBuf) -> io::Result<bool> {
Ok(true)
}
/// todo
#[cfg(target_os = "macos")]
pub fn set_startup(enable: bool, exe_path: &PathBuf) -> io::Result<()> {
Ok(())
}
#[cfg(target_os = "windows")]
#[test]
fn test() {
let path = PathBuf::from(r"D:\Software\Clash Verge\clash-verge.exe");
assert!(set_startup(true, &path).is_ok());
assert_eq!(get_startup(&path).unwrap(), true);
assert!(set_startup(false, &path).is_ok());
assert_eq!(get_startup(&path).unwrap(), false);
}

View File

@@ -1,7 +1,7 @@
{
"package": {
"productName": "clash-verge",
"version": "0.0.9"
"version": "0.0.11"
},
"build": {
"distDir": "../dist",

View File

@@ -28,3 +28,4 @@ body {
}
@import "./layout.scss";
@import "./page.scss";

View File

@@ -27,6 +27,10 @@
text-align: center;
box-sizing: border-box;
img {
width: 100%;
}
.the-newbtn {
position: absolute;
right: 20px;
@@ -54,27 +58,24 @@
position: relative;
flex: 1 1 75%;
height: 100%;
display: flex;
flex-direction: column;
padding: 2px 0;
box-sizing: border-box;
.the-bar {
flex: 0 0 30px;
width: 100%;
height: 30px;
padding: 0 16px;
position: absolute;
top: 2px;
right: 8px;
height: 36px;
display: flex;
align-items: center;
justify-content: flex-end;
box-sizing: border-box;
z-index: 2;
}
.the-content {
flex: 1 1 100%;
overflow: auto;
box-sizing: border-box;
scrollbar-gutter: stable;
position: absolute;
left: 0;
right: 0;
top: 30px;
bottom: 10px;
}
}
}

View File

@@ -0,0 +1,33 @@
.base-page {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
> header {
flex: 0 0 58px;
width: 90%;
max-width: 850px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
> section {
position: relative;
flex: 1 1 100%;
width: 100%;
height: 100%;
overflow: auto;
padding: 8px 0;
box-sizing: border-box;
scrollbar-gutter: stable;
.base-content {
width: 90%;
max-width: 850px;
margin: 0 auto;
}
}
}

View File

@@ -0,0 +1,32 @@
import { Typography } from "@mui/material";
import React from "react";
interface Props {
title?: React.ReactNode; // the page title
header?: React.ReactNode; // something behind title
contentStyle?: React.CSSProperties;
}
const BasePage: React.FC<Props> = (props) => {
const { title, header, contentStyle, children } = props;
return (
<div className="base-page" data-windrag>
<header data-windrag>
<Typography variant="h4" component="h1">
{title}
</Typography>
{header}
</header>
<section data-windrag>
<div className="base-content" style={contentStyle} data-windrag>
{children}
</div>
</section>
</div>
);
};
export default BasePage;

View File

@@ -0,0 +1,41 @@
import { Button } from "@mui/material";
import { appWindow } from "@tauri-apps/api/window";
import {
CloseRounded,
CropSquareRounded,
HorizontalRuleRounded,
} from "@mui/icons-material";
const LayoutControl = () => {
const minWidth = 40;
return (
<>
<Button
size="small"
sx={{ minWidth, svg: { transform: "scale(0.9)" } }}
onClick={() => appWindow.minimize()}
>
<HorizontalRuleRounded fontSize="small" />
</Button>
<Button
size="small"
sx={{ minWidth, svg: { transform: "scale(0.9)" } }}
onClick={() => appWindow.toggleMaximize()}
>
<CropSquareRounded fontSize="small" />
</Button>
<Button
size="small"
sx={{ minWidth, svg: { transform: "scale(1.05)" } }}
onClick={() => appWindow.hide()}
>
<CloseRounded fontSize="small" />
</Button>
</>
);
};
export default LayoutControl;

92
src/components/notice.tsx Normal file
View File

@@ -0,0 +1,92 @@
import ReactDOM from "react-dom";
import { ReactNode, useState } from "react";
import { Box, IconButton, Slide, Snackbar, Typography } from "@mui/material";
import { Close, CheckCircleRounded, ErrorRounded } from "@mui/icons-material";
interface InnerProps {
type: string;
duration?: number;
message: ReactNode;
onClose: () => void;
}
const NoticeInner = (props: InnerProps) => {
const { type, message, duration = 2000, onClose } = props;
const [visible, setVisible] = useState(true);
const onBtnClose = () => {
setVisible(false);
onClose();
};
const onAutoClose = (_e: any, reason: string) => {
if (reason !== "clickaway") onBtnClose();
};
const msgElement =
type === "info" ? (
message
) : (
<Box sx={{ display: "flex", alignItems: "center" }}>
{type === "error" && <ErrorRounded color="error" />}
{type === "success" && <CheckCircleRounded color="success" />}
<Typography
sx={{ ml: 1, wordWrap: "break-word", wordBreak: "break-all" }}
>
{message}
</Typography>
</Box>
);
return (
<Snackbar
open={visible}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
autoHideDuration={duration}
onClose={onAutoClose}
message={msgElement}
sx={{ maxWidth: 360 }}
TransitionComponent={(p) => <Slide {...p} direction="left" />}
transitionDuration={200}
action={
<IconButton size="small" color="inherit" onClick={onBtnClose}>
<Close fontSize="inherit" />
</IconButton>
}
/>
);
};
interface NoticeInstance {
(props: Omit<InnerProps, "onClose">): void;
info(message: ReactNode, duration?: number): void;
error(message: ReactNode, duration?: number): void;
success(message: ReactNode, duration?: number): void;
}
let parent: HTMLDivElement = null!;
// @ts-ignore
const Notice: NoticeInstance = (props) => {
if (!parent) {
parent = document.createElement("div");
document.body.appendChild(parent);
}
const container = document.createElement("div");
parent.appendChild(container);
const onUnmount = () => {
const result = ReactDOM.unmountComponentAtNode(container);
if (result && parent) parent.removeChild(container);
};
ReactDOM.render(<NoticeInner {...props} onClose={onUnmount} />, container);
};
(["info", "error", "success"] as const).forEach((type) => {
Notice[type] = (message, duration) => Notice({ type, message, duration });
});
export default Notice;

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useRef, useState } from "react";
import dayjs from "dayjs";
import {
alpha,
@@ -14,7 +14,8 @@ import {
import { useSWRConfig } from "swr";
import { RefreshRounded } from "@mui/icons-material";
import { CmdType } from "../services/types";
import { updateProfile, deleteProfile } from "../services/cmds";
import { updateProfile, deleteProfile, editProfile } from "../services/cmds";
import Notice from "./notice";
import parseTraffic from "../utils/parse-traffic";
import relativeTime from "dayjs/plugin/relativeTime";
@@ -32,19 +33,19 @@ const Wrapper = styled(Box)(({ theme }) => ({
}));
const round = keyframes`
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
`;
interface Props {
index: number;
selected: boolean;
itemData: CmdType.ProfileItem;
onClick: () => void;
onSelect: (force: boolean) => void;
}
const ProfileItemComp: React.FC<Props> = (props) => {
const { index, selected, itemData, onClick } = props;
const ProfileItem: React.FC<Props> = (props) => {
const { index, selected, itemData, onSelect } = props;
const { mutate } = useSWRConfig();
const [loading, setLoading] = useState(false);
@@ -58,27 +59,46 @@ const ProfileItemComp: React.FC<Props> = (props) => {
const progress = Math.round(((download + upload) * 100) / (total + 0.1));
const fromnow = updated > 0 ? dayjs(updated * 1000).fromNow() : "";
const onUpdate = async () => {
const onEdit = async () => {
setAnchorEl(null);
try {
await editProfile(index);
} catch (err: any) {
Notice.error(err.toString());
}
};
const onForceSelect = () => {
setAnchorEl(null);
onSelect(true);
};
const onUpdateWrapper = (withProxy: boolean) => async () => {
setAnchorEl(null);
if (loading) return;
setLoading(true);
try {
await updateProfile(index);
await updateProfile(index, withProxy);
mutate("getProfiles");
} catch (err) {
console.error(err);
} catch (err: any) {
Notice.error(err.toString());
} finally {
setLoading(false);
}
};
const deleteRef = useRef(false);
const onDelete = async () => {
setAnchorEl(null);
if (deleteRef.current) return;
deleteRef.current = true;
try {
await deleteProfile(index);
mutate("getProfiles");
} catch (err) {
console.error(err);
} catch (err: any) {
Notice.error(err.toString());
} finally {
deleteRef.current = false;
}
};
@@ -121,7 +141,7 @@ const ProfileItemComp: React.FC<Props> = (props) => {
return { bgcolor, color, "& h2": { color: h2color } };
}}
onClick={onClick}
onClick={() => onSelect(false)}
onContextMenu={handleContextMenu}
>
<Box display="flex" justifyContent="space-between">
@@ -145,7 +165,7 @@ const ProfileItemComp: React.FC<Props> = (props) => {
disabled={loading}
onClick={(e) => {
e.stopPropagation();
onUpdate();
onUpdateWrapper(false)();
}}
>
<RefreshRounded />
@@ -196,9 +216,11 @@ const ProfileItemComp: React.FC<Props> = (props) => {
anchorPosition={position}
anchorReference="anchorPosition"
>
<MenuItem onClick={onUpdate}>Update</MenuItem>
<MenuItem onClick={onForceSelect}>Select</MenuItem>
<MenuItem onClick={onEdit}>Edit(VScode)</MenuItem>
<MenuItem onClick={onUpdateWrapper(false)}>Update</MenuItem>
<MenuItem onClick={onUpdateWrapper(true)}>Update(Proxy)</MenuItem>
<MenuItem onClick={onDelete}>Delete</MenuItem>
{/* <MenuItem>Update(proxy)</MenuItem> */}
</Menu>
</>
);
@@ -216,4 +238,4 @@ function parseExpire(expire?: number) {
return dayjs(expire * 1000).format("YYYY-MM-DD");
}
export default ProfileItemComp;
export default ProfileItem;

View File

@@ -1,15 +1,12 @@
import { useState } from "react";
import { Virtuoso } from "react-virtuoso";
import {
alpha,
Box,
Collapse,
Divider,
IconButton,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
} from "@mui/material";
import {
@@ -18,53 +15,11 @@ import {
ExpandMoreRounded,
MyLocationRounded,
NetworkCheckRounded,
CheckCircleOutlineRounded,
} from "@mui/icons-material";
import { updateProxy } from "../services/api";
import { ApiType } from "../services/types";
import { getProfiles, patchProfile } from "../services/cmds";
interface ItemProps {
proxy: ApiType.ProxyItem;
selected: boolean;
onClick?: (name: string) => void;
}
const Item = ({ proxy, selected, onClick }: ItemProps) => {
return (
<ListItem sx={{ py: 0, pl: 4 }}>
<ListItemButton
dense
selected={selected}
onClick={() => onClick?.(proxy.name)}
sx={[
{
borderRadius: 1,
},
({ palette: { mode, primary } }) => {
const bgcolor =
mode === "light"
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35);
const color = mode === "light" ? primary.main : primary.light;
return {
"&.Mui-selected": { bgcolor },
"&.Mui-selected .MuiListItemText-secondary": { color },
};
},
]}
>
<ListItemText title={proxy.name} secondary={proxy.name} />
<ListItemIcon
sx={{ justifyContent: "flex-end", color: "primary.main" }}
>
{selected && <CheckCircleOutlineRounded sx={{ fontSize: 16 }} />}
</ListItemIcon>
</ListItemButton>
</ListItem>
);
};
import ProxyItem from "./proxy-item";
interface Props {
group: ApiType.ProxyGroupItem;
@@ -143,12 +98,13 @@ const ProxyGroup = ({ group }: Props) => {
{proxies.length >= 10 ? (
<Virtuoso
style={{ height: "400px", marginBottom: "4px" }}
style={{ height: "320px", marginBottom: "4px" }}
totalCount={proxies.length}
itemContent={(index) => (
<Item
<ProxyItem
proxy={proxies[index]}
selected={proxies[index].name === now}
sx={{ py: 0, pl: 4 }}
onClick={onUpdate}
/>
)}
@@ -157,13 +113,14 @@ const ProxyGroup = ({ group }: Props) => {
<List
component="div"
disablePadding
sx={{ maxHeight: "400px", overflow: "auto", mb: "4px" }}
sx={{ maxHeight: "320px", overflow: "auto", mb: "4px" }}
>
{proxies.map((proxy) => (
<Item
<ProxyItem
key={proxy.name}
proxy={proxy}
selected={proxy.name === now}
sx={{ py: 0, pl: 4 }}
onClick={onUpdate}
/>
))}

View File

@@ -0,0 +1,58 @@
import { CheckCircleOutlineRounded } from "@mui/icons-material";
import {
alpha,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
SxProps,
Theme,
} from "@mui/material";
import { ApiType } from "../services/types";
interface Props {
proxy: ApiType.ProxyItem;
selected: boolean;
sx?: SxProps<Theme>;
onClick?: (name: string) => void;
}
const ProxyItem = (props: Props) => {
const { proxy, selected, sx, onClick } = props;
return (
<ListItem sx={sx}>
<ListItemButton
dense
selected={selected}
onClick={() => onClick?.(proxy.name)}
sx={[
{
borderRadius: 1,
},
({ palette: { mode, primary } }) => {
const bgcolor =
mode === "light"
? alpha(primary.main, 0.15)
: alpha(primary.main, 0.35);
const color = mode === "light" ? primary.main : primary.light;
return {
"&.Mui-selected": { bgcolor },
"&.Mui-selected .MuiListItemText-secondary": { color },
};
},
]}
>
<ListItemText title={proxy.name} secondary={proxy.name} />
<ListItemIcon
sx={{ justifyContent: "flex-end", color: "primary.main" }}
>
{selected && <CheckCircleOutlineRounded sx={{ fontSize: 16 }} />}
</ListItemIcon>
</ListItemButton>
</ListItem>
);
};
export default ProxyItem;

View File

@@ -1,8 +0,0 @@
import { ListItem, styled } from "@mui/material";
const SettingItem = styled(ListItem)(() => ({
paddingTop: 5,
paddingBottom: 5,
}));
export default SettingItem;

View File

@@ -1,117 +0,0 @@
import useSWR, { useSWRConfig } from "swr";
import {
Box,
List,
ListItemText,
ListSubheader,
Switch,
Typography,
} from "@mui/material";
import { getVergeConfig, patchVergeConfig } from "../services/cmds";
import { CmdType } from "../services/types";
import { version } from "../../package.json";
import GuardState from "./guard-state";
import SettingItem from "./setting-item";
import PaletteSwitch from "./palette-switch";
import SysproxyTooltip from "./sysproxy-tooltip";
interface Props {
onError?: (err: Error) => void;
}
const SettingVerge = ({ onError }: Props) => {
const { mutate } = useSWRConfig();
const { data: vergeConfig } = useSWR("getVergeConfig", getVergeConfig);
const {
theme_mode: mode = "light",
theme_blur: blur = false,
enable_self_startup: startup = false,
enable_system_proxy: proxy = false,
} = vergeConfig ?? {};
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<CmdType.VergeConfig>) => {
mutate("getVergeConfig", { ...vergeConfig, ...patch }, false);
};
return (
<List>
<ListSubheader sx={{ background: "transparent" }}>
Common Setting
</ListSubheader>
<SettingItem>
<ListItemText primary="Theme Mode" />
<GuardState
value={mode === "dark"}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ theme_mode: e ? "dark" : "light" })}
onGuard={(c) =>
patchVergeConfig({ theme_mode: c ? "dark" : "light" })
}
>
<PaletteSwitch edge="end" />
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText primary="Theme Blur" />
<GuardState
value={blur}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ theme_blur: e })}
onGuard={(e) => patchVergeConfig({ theme_blur: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText primary="Self Startup" />
<GuardState
value={startup}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_self_startup: e })}
onGuard={(e) => patchVergeConfig({ enable_self_startup: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText
primary={
<Box sx={{ display: "flex", alignItems: "center" }}>
System Proxy
<SysproxyTooltip />
</Box>
}
/>
<GuardState
value={proxy}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_system_proxy: e })}
onGuard={(e) => patchVergeConfig({ enable_system_proxy: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText primary="Version" />
<Typography sx={{ py: "6px" }}>v{version}</Typography>
</SettingItem>
</List>
);
};
export default SettingVerge;

View File

@@ -1,5 +1,5 @@
import { cloneElement, isValidElement, ReactNode, useRef } from "react";
import noop from "../utils/noop";
import noop from "../../utils/noop";
interface Props<Value> {
value?: Value;

View File

@@ -1,18 +1,16 @@
import useSWR, { useSWRConfig } from "swr";
import {
List,
ListItemText,
ListSubheader,
TextField,
Switch,
Select,
MenuItem,
} from "@mui/material";
import { getClashConfig, updateConfigs } from "../services/api";
import { patchClashConfig } from "../services/cmds";
import { ApiType } from "../services/types";
import { getClashConfig, updateConfigs } from "../../services/api";
import { SettingList, SettingItem } from "./setting";
import { patchClashConfig } from "../../services/cmds";
import { ApiType } from "../../services/types";
import GuardState from "./guard-state";
import SettingItem from "./setting-item";
interface Props {
onError?: (err: Error) => void;
@@ -30,22 +28,16 @@ const SettingClash = ({ onError }: Props) => {
} = clashConfig ?? {};
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<ApiType.ConfigData>) => {
mutate("getClashConfig", { ...clashConfig, ...patch }, false);
};
const onUpdateData = async (patch: Partial<ApiType.ConfigData>) => {
await updateConfigs(patch);
await patchClashConfig(patch);
};
return (
<List>
<ListSubheader sx={{ background: "transparent" }}>
Clash Setting
</ListSubheader>
<SettingList title="Clash Setting">
<SettingItem>
<ListItemText primary="Allow Lan" />
<GuardState
@@ -102,7 +94,7 @@ const SettingClash = ({ onError }: Props) => {
disabled
/>
</SettingItem>
</List>
</SettingList>
);
};

View File

@@ -0,0 +1,67 @@
import useSWR, { useSWRConfig } from "swr";
import { Box, ListItemText, Switch } from "@mui/material";
import { getVergeConfig, patchVergeConfig } from "../../services/cmds";
import { SettingList, SettingItem } from "./setting";
import { CmdType } from "../../services/types";
import GuardState from "./guard-state";
import SysproxyTooltip from "./sysproxy-tooltip";
interface Props {
onError?: (err: Error) => void;
}
const SettingSystem = ({ onError }: Props) => {
const { mutate } = useSWRConfig();
const { data: vergeConfig } = useSWR("getVergeConfig", getVergeConfig);
const {
enable_auto_launch: startup = false,
enable_system_proxy: proxy = false,
} = vergeConfig ?? {};
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<CmdType.VergeConfig>) => {
mutate("getVergeConfig", { ...vergeConfig, ...patch }, false);
};
return (
<SettingList title="System Setting">
<SettingItem>
<ListItemText primary="Auto Launch" />
<GuardState
value={startup}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_auto_launch: e })}
onGuard={(e) => patchVergeConfig({ enable_auto_launch: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText
primary={
<Box sx={{ display: "flex", alignItems: "center" }}>
System Proxy
<SysproxyTooltip />
</Box>
}
/>
<GuardState
value={proxy}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ enable_system_proxy: e })}
onGuard={(e) => patchVergeConfig({ enable_system_proxy: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
</SettingList>
);
};
export default SettingSystem;

View File

@@ -0,0 +1,66 @@
import useSWR, { useSWRConfig } from "swr";
import { ListItemText, Switch, Typography } from "@mui/material";
import { getVergeConfig, patchVergeConfig } from "../../services/cmds";
import { SettingList, SettingItem } from "./setting";
import { CmdType } from "../../services/types";
import { version } from "../../../package.json";
import GuardState from "./guard-state";
import PaletteSwitch from "./palette-switch";
interface Props {
onError?: (err: Error) => void;
}
const SettingVerge = ({ onError }: Props) => {
const { mutate } = useSWRConfig();
const { data: vergeConfig } = useSWR("getVergeConfig", getVergeConfig);
const { theme_mode: mode = "light", theme_blur: blur = false } =
vergeConfig ?? {};
const onSwitchFormat = (_e: any, value: boolean) => value;
const onChangeData = (patch: Partial<CmdType.VergeConfig>) => {
mutate("getVergeConfig", { ...vergeConfig, ...patch }, false);
};
return (
<SettingList title="Verge Setting">
<SettingItem>
<ListItemText primary="Theme Mode" />
<GuardState
value={mode === "dark"}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ theme_mode: e ? "dark" : "light" })}
onGuard={(c) =>
patchVergeConfig({ theme_mode: c ? "dark" : "light" })
}
>
<PaletteSwitch edge="end" />
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText primary="Theme Blur" />
<GuardState
value={blur}
valueProps="checked"
onCatch={onError}
onFormat={onSwitchFormat}
onChange={(e) => onChangeData({ theme_blur: e })}
onGuard={(e) => patchVergeConfig({ theme_blur: e })}
>
<Switch edge="end" />
</GuardState>
</SettingItem>
<SettingItem>
<ListItemText primary="Version" />
<Typography sx={{ py: "6px" }}>v{version}</Typography>
</SettingItem>
</SettingList>
);
};
export default SettingVerge;

View File

@@ -0,0 +1,17 @@
import React from "react";
import { List, ListItem, ListSubheader, styled } from "@mui/material";
export const SettingItem = styled(ListItem)(() => ({
paddingTop: 5,
paddingBottom: 5,
}));
export const SettingList: React.FC<{ title: string }> = (props) => (
<List>
<ListSubheader sx={{ background: "transparent" }} disableSticky>
{props.title}
</ListSubheader>
{props.children}
</List>
);

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { InfoRounded } from "@mui/icons-material";
import { ClickAwayListener, Tooltip } from "@mui/material";
import { getSystemProxy } from "../services/cmds";
import { getSystemProxy } from "../../services/cmds";
const SysproxyTooltip = () => {
const [open, setOpen] = useState(false);
@@ -9,7 +9,6 @@ const SysproxyTooltip = () => {
const onShow = async () => {
const data = await getSystemProxy();
console.log(data);
setInfo(data ?? {});
setOpen(true);
};

View File

@@ -0,0 +1,40 @@
import useSWR from "swr";
import { useState } from "react";
import { Button } from "@mui/material";
import { checkUpdate } from "@tauri-apps/api/updater";
import UpdateDialog from "./update-dialog";
interface Props {
className?: string;
}
const UpdateButton = (props: Props) => {
const { className } = props;
const [dialogOpen, setDialogOpen] = useState(false);
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
});
if (!updateInfo?.shouldUpdate) return null;
return (
<>
<Button
color="error"
variant="contained"
size="small"
className={className}
onClick={() => setDialogOpen(true)}
>
New
</Button>
<UpdateDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</>
);
};
export default UpdateButton;

View File

@@ -1,68 +1,28 @@
import { useEffect, useMemo, useState } from "react";
import useSWR, { SWRConfig } from "swr";
import { useEffect, useMemo } from "react";
import { Route, Routes } from "react-router-dom";
import { useRecoilState } from "recoil";
import {
alpha,
Button,
createTheme,
IconButton,
List,
Paper,
ThemeProvider,
} from "@mui/material";
import { HorizontalRuleRounded, CloseRounded } from "@mui/icons-material";
import { checkUpdate } from "@tauri-apps/api/updater";
import { alpha, createTheme, List, Paper, ThemeProvider } from "@mui/material";
import { appWindow } from "@tauri-apps/api/window";
import { atomPaletteMode, atomThemeBlur } from "../states/setting";
import { getVergeConfig, windowDrag, windowHide } from "../services/cmds";
import { getVergeConfig } from "../services/cmds";
import { routers } from "./_routers";
import LogoSvg from "../assets/image/logo.svg";
import LogPage from "./log";
import ProfilePage from "./profile";
import ProxyPage from "./proxy";
import SettingPage from "./setting";
import ConnectionsPage from "./connections";
import LayoutItem from "../components/layout-item";
import Traffic from "../components/traffic";
import UpdateDialog from "../components/update-dialog";
const routers = [
{
label: "Proxy",
link: "/",
ele: ProxyPage,
},
{
label: "Profile",
link: "/profile",
ele: ProfilePage,
},
{
label: "Connections",
link: "/connections",
ele: ConnectionsPage,
},
{
label: "Log",
link: "/log",
ele: LogPage,
},
{
label: "Setting",
link: "/setting",
ele: SettingPage,
},
];
import LayoutItem from "../components/layout-item";
import UpdateButton from "../components/update-button";
import LayoutControl from "../components/layout-control";
const Layout = () => {
const [mode, setMode] = useRecoilState(atomPaletteMode);
const [blur, setBlur] = useRecoilState(atomThemeBlur);
const { data: vergeConfig } = useSWR("getVergeConfig", getVergeConfig);
const { data: updateInfo } = useSWR("checkUpdate", checkUpdate, {
errorRetryCount: 2,
revalidateIfStale: false,
focusThrottleInterval: 36e5, // 1 hour
});
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => {
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") appWindow.hide();
});
}, []);
useEffect(() => {
if (!vergeConfig) return;
@@ -90,6 +50,12 @@ const Layout = () => {
});
}, [mode]);
const onDragging = (e: any) => {
if (e?.target?.dataset?.windrag) {
appWindow.startDragging();
}
};
return (
<SWRConfig value={{}}>
<ThemeProvider theme={theme}>
@@ -97,38 +63,21 @@ const Layout = () => {
square
elevation={0}
className="layout"
onPointerDown={onDragging}
sx={[
(theme) => ({
bgcolor: alpha(theme.palette.background.paper, blur ? 0.85 : 1),
}),
]}
>
<div className="layout__left">
<div className="the-logo">
<img
src={LogoSvg}
width="100%"
alt=""
onPointerDown={(e) => {
windowDrag();
e.preventDefault();
}}
/>
<div className="layout__left" data-windrag>
<div className="the-logo" data-windrag>
<img src={LogoSvg} alt="" data-windrag />
{updateInfo?.shouldUpdate && (
<Button
color="error"
variant="contained"
size="small"
className="the-newbtn"
onClick={() => setDialogOpen(true)}
>
New
</Button>
)}
<UpdateButton className="the-newbtn" />
</div>
<List className="the-menu">
<List className="the-menu" data-windrag>
{routers.map((router) => (
<LayoutItem key={router.label} to={router.link}>
{router.label}
@@ -136,29 +85,17 @@ const Layout = () => {
))}
</List>
<div className="the-traffic">
<div className="the-traffic" data-windrag>
<Traffic />
</div>
</div>
<div className="layout__right">
<div
className="the-bar"
onPointerDown={(e) =>
e.target === e.currentTarget && windowDrag()
}
>
{/* todo: onClick = windowMini */}
<IconButton size="small" sx={{ mx: 1 }} onClick={windowHide}>
<HorizontalRuleRounded fontSize="inherit" />
</IconButton>
<IconButton size="small" onClick={windowHide}>
<CloseRounded fontSize="inherit" />
</IconButton>
<div className="layout__right" data-windrag>
<div className="the-bar">
<LayoutControl />
</div>
<div className="the-content">
<div className="the-content" data-windrag>
<Routes>
{routers.map(({ label, link, ele: Ele }) => (
<Route key={label} path={link} element={<Ele />} />
@@ -167,7 +104,6 @@ const Layout = () => {
</div>
</div>
</Paper>
<UpdateDialog open={dialogOpen} onClose={() => setDialogOpen(false)} />
</ThemeProvider>
</SWRConfig>
);

33
src/pages/_routers.tsx Normal file
View File

@@ -0,0 +1,33 @@
import LogsPage from "./logs";
import ProxiesPage from "./proxies";
import ProfilesPage from "./profiles";
import SettingsPage from "./settings";
import ConnectionsPage from "./connections";
export const routers = [
{
label: "Proxies",
link: "/",
ele: ProxiesPage,
},
{
label: "Profiles",
link: "/profile",
ele: ProfilesPage,
},
{
label: "Connections",
link: "/connections",
ele: ConnectionsPage,
},
{
label: "Logs",
link: "/logs",
ele: LogsPage,
},
{
label: "Settings",
link: "/settings",
ele: SettingsPage,
},
];

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { Box, Paper, Typography } from "@mui/material";
import { Paper } from "@mui/material";
import { Virtuoso } from "react-virtuoso";
import { getInfomation } from "../services/api";
import { ApiType } from "../services/types";
import { getInfomation } from "../services/api";
import BasePage from "../components/base-page";
import ConnectionItem from "../components/connection-item";
const ConnectionsPage = () => {
@@ -26,25 +27,14 @@ const ConnectionsPage = () => {
}, []);
return (
<Box
sx={{
width: 0.9,
maxWidth: "850px",
height: "100%",
mx: "auto",
}}
>
<Typography variant="h4" component="h1" sx={{ py: 2 }}>
Connections
</Typography>
<Paper sx={{ boxShadow: 2, height: "calc(100% - 100px)" }}>
<BasePage title="Connections" contentStyle={{ height: "100%" }}>
<Paper sx={{ boxShadow: 2, height: "100%" }}>
<Virtuoso
data={conn.connections}
itemContent={(index, item) => <ConnectionItem value={item} />}
/>
</Paper>
</Box>
</BasePage>
);
};

View File

@@ -1,9 +1,10 @@
import dayjs from "dayjs";
import { useEffect, useRef, useState } from "react";
import { Box, Button, Paper, Typography } from "@mui/material";
import { useEffect, useState } from "react";
import { Button, Paper } from "@mui/material";
import { Virtuoso } from "react-virtuoso";
import { ApiType } from "../services/types";
import { getInfomation } from "../services/api";
import BasePage from "../components/base-page";
import LogItem from "../components/log-item";
let logCache: ApiType.LogItem[] = [];
@@ -28,33 +29,27 @@ const LogPage = () => {
return () => ws?.close();
}, []);
const onClear = () => {
setLogData([]);
logCache = [];
};
return (
<Box
sx={{
position: "relative",
width: 0.9,
maxWidth: "850px",
height: "100%",
mx: "auto",
}}
<BasePage
title="Logs"
contentStyle={{ height: "100%" }}
header={
<Button
size="small"
sx={{ mt: 1 }}
variant="contained"
onClick={onClear}
>
Clear
</Button>
}
>
<Typography variant="h4" component="h1" sx={{ py: 2 }}>
Logs
</Typography>
<Button
size="small"
variant="contained"
sx={{ position: "absolute", top: 22, right: 0 }}
onClick={() => {
setLogData([]);
logCache = [];
}}
>
Clear
</Button>
<Paper sx={{ boxShadow: 2, height: "calc(100% - 100px)" }}>
<Paper sx={{ boxShadow: 2, height: "100%" }}>
<Virtuoso
initialTopMostItemIndex={999}
data={logData}
@@ -62,7 +57,7 @@ const LogPage = () => {
followOutput={"smooth"}
/>
</Paper>
</Box>
</BasePage>
);
};

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import useSWR, { useSWRConfig } from "swr";
import { Box, Button, Grid, TextField, Typography } from "@mui/material";
import { useEffect, useRef, useState } from "react";
import { Box, Button, Grid, TextField } from "@mui/material";
import {
getProfiles,
selectProfile,
@@ -8,14 +8,14 @@ import {
importProfile,
} from "../services/cmds";
import { getProxies, updateProxy } from "../services/api";
import ProfileItemComp from "../components/profile-item";
import useNotice from "../utils/use-notice";
import noop from "../utils/noop";
import Notice from "../components/notice";
import BasePage from "../components/base-page";
import ProfileItem from "../components/profile-item";
const ProfilePage = () => {
const [url, setUrl] = useState("");
const [disabled, setDisabled] = useState(false);
const [notice, noticeElement] = useNotice();
const { mutate } = useSWRConfig();
const { data: profiles = {} } = useSWR("getProfiles", getProfiles);
@@ -71,37 +71,31 @@ const ProfilePage = () => {
await importProfile(url);
mutate("getProfiles", getProfiles());
if (!profiles.items?.length) selectProfile(0).catch(noop);
notice.success("Successfully import profile.");
Notice.success("Successfully import profile.");
} catch {
notice.error("Failed to import profile.");
Notice.error("Failed to import profile.");
} finally {
setDisabled(false);
}
};
const lockRef = useRef(false);
const onProfileChange = (index: number) => {
if (index === profiles.current || lockRef.current) return;
const onSelect = async (index: number, force: boolean) => {
if (lockRef.current) return;
if (!force && index === profiles.current) return;
lockRef.current = true;
selectProfile(index)
.then(() => {
mutate("getProfiles", { ...profiles, current: index }, true);
})
.catch((err) => {
console.error(err);
})
.finally(() => {
lockRef.current = false;
});
try {
await selectProfile(index);
mutate("getProfiles", { ...profiles, current: index }, true);
} catch (err: any) {
err && Notice.error(err.toString());
} finally {
lockRef.current = false;
}
};
return (
<Box sx={{ width: 0.9, maxWidth: "850px", mx: "auto", mb: 2 }}>
<Typography variant="h4" component="h1" sx={{ py: 2, mb: 1 }}>
Profiles
</Typography>
<BasePage title="Profiles">
<Box sx={{ display: "flex", mb: 3 }}>
<TextField
id="profile_url"
@@ -111,7 +105,7 @@ const ProfilePage = () => {
fullWidth
value={url}
onChange={(e) => setUrl(e.target.value)}
sx={{ mr: 4 }}
sx={{ mr: 2 }}
/>
<Button
disabled={!url || disabled}
@@ -125,18 +119,16 @@ const ProfilePage = () => {
<Grid container spacing={3}>
{profiles?.items?.map((item, idx) => (
<Grid item xs={12} sm={6} key={item.file}>
<ProfileItemComp
<ProfileItem
index={idx}
selected={profiles.current === idx}
itemData={item}
onClick={() => onProfileChange(idx)}
onSelect={(f) => onSelect(idx, f)}
/>
</Grid>
))}
</Grid>
{noticeElement}
</Box>
</BasePage>
);
};

View File

@@ -1,13 +1,15 @@
import useSWR, { useSWRConfig } from "swr";
import { useEffect } from "react";
import { Box, List, Paper, Typography } from "@mui/material";
import { List, Paper } from "@mui/material";
import { getProxies } from "../services/api";
import BasePage from "../components/base-page";
import ProxyItem from "../components/proxy-item";
import ProxyGroup from "../components/proxy-group";
const ProxyPage = () => {
const { mutate } = useSWRConfig();
const { data: proxiesData } = useSWR("getProxies", getProxies);
const { groups = [] } = proxiesData ?? {};
const { groups = [], proxies = [] } = proxiesData ?? {};
useEffect(() => {
// fix the empty proxies on the first sight
@@ -18,21 +20,30 @@ const ProxyPage = () => {
}, []);
return (
<Box sx={{ width: 0.9, maxWidth: "850px", mx: "auto", mb: 2 }}>
<Typography variant="h4" component="h1" sx={{ py: 2 }}>
Proxy Groups
</Typography>
{groups.length > 0 && (
<Paper sx={{ borderRadius: 1, boxShadow: 2 }}>
<BasePage title={groups.length ? "Proxy Groups" : "Proxies"}>
<Paper sx={{ borderRadius: 1, boxShadow: 2, mb: 1 }}>
{groups.length > 0 && (
<List>
{groups.map((group) => (
<ProxyGroup key={group.name} group={group} />
))}
</List>
</Paper>
)}
</Box>
)}
{!groups.length && (
<List>
{Object.values(proxies).map((proxy) => (
<ProxyItem
key={proxy.name}
proxy={proxy}
selected={false}
sx={{ py: 0, px: 2 }}
/>
))}
</List>
)}
</Paper>
</BasePage>
);
};

View File

@@ -1,23 +0,0 @@
import { Box, Paper, Typography } from "@mui/material";
import SettingVerge from "../components/setting-verge";
import SettingClash from "../components/setting-clash";
const SettingPage = () => {
return (
<Box sx={{ width: 0.9, maxWidth: 850, mx: "auto", mb: 2 }}>
<Typography variant="h4" component="h1" sx={{ py: 2 }}>
Setting
</Typography>
<Paper sx={{ borderRadius: 1, boxShadow: 2 }}>
<SettingVerge />
</Paper>
<Paper sx={{ borderRadius: 1, boxShadow: 2, mt: 3 }}>
<SettingClash />
</Paper>
</Box>
);
};
export default SettingPage;

30
src/pages/settings.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { Paper } from "@mui/material";
import BasePage from "../components/base-page";
import SettingVerge from "../components/setting/setting-verge";
import SettingClash from "../components/setting/setting-clash";
import SettingSystem from "../components/setting/setting-system";
import Notice from "../components/notice";
const SettingPage = () => {
const onError = (error: any) => {
error && Notice.error(error.toString());
};
return (
<BasePage title="Settings">
<Paper sx={{ borderRadius: 1, boxShadow: 2, mb: 3 }}>
<SettingClash onError={onError} />
</Paper>
<Paper sx={{ borderRadius: 1, boxShadow: 2, mb: 3 }}>
<SettingSystem onError={onError} />
</Paper>
<Paper sx={{ borderRadius: 1, boxShadow: 2 }}>
<SettingVerge onError={onError} />
</Paper>
</BasePage>
);
};
export default SettingPage;

View File

@@ -9,12 +9,16 @@ export async function syncProfiles() {
return invoke<void>("sync_profiles");
}
export async function importProfile(url: string) {
return invoke<void>("import_profile", { url });
export async function editProfile(index: number) {
return invoke<void>("edit_profile", { index });
}
export async function updateProfile(index: number) {
return invoke<void>("update_profile", { index });
export async function importProfile(url: string) {
return invoke<void>("import_profile", { url, withProxy: true });
}
export async function updateProfile(index: number, withProxy: boolean) {
return invoke<void>("update_profile", { index, withProxy });
}
export async function deleteProfile(index: number) {
@@ -36,18 +40,6 @@ export async function restartSidecar() {
return invoke<void>("restart_sidecar");
}
export async function windowDrag() {
return invoke<void>("win_drag");
}
export async function windowHide() {
return invoke<void>("win_hide");
}
export async function windowMini() {
return invoke<void>("win_mini");
}
export async function getClashInfo() {
return invoke<CmdType.ClashInfo | null>("get_clash_info");
}

View File

@@ -111,7 +111,7 @@ export namespace CmdType {
export interface VergeConfig {
theme_mode?: "light" | "dark";
theme_blur?: boolean;
enable_self_startup?: boolean;
enable_auto_launch?: boolean;
enable_system_proxy?: boolean;
}
}

View File

@@ -1,68 +0,0 @@
import { useMemo, useState } from "react";
import { Box, IconButton, Slide, Snackbar } from "@mui/material";
import { Close, CheckCircleRounded, ErrorRounded } from "@mui/icons-material";
interface NoticeInstance {
info: (msg: string) => void;
error: (msg: string) => void;
success: (msg: string) => void;
}
const useNotice = () => {
const [message, setMessage] = useState("");
const [level, setLevel] = useState<"info" | "error" | "success">("info");
const handleClose = (_e: any, reason: string) => {
if (reason !== "clickaway") setMessage("");
};
const msgElement =
level === "info" ? (
message
) : (
<Box sx={{ display: "flex", alignItems: "center" }}>
{level === "error" && <ErrorRounded color="error" />}
{level === "success" && <CheckCircleRounded color="success" />}
<span style={{ marginLeft: 4 }}>{message}</span>
</Box>
);
const element = useMemo(
() => (
<Snackbar
open={!!message}
anchorOrigin={{ vertical: "top", horizontal: "right" }}
autoHideDuration={3000}
onClose={handleClose}
message={msgElement}
sx={{ maxWidth: 360 }}
TransitionComponent={(p) => <Slide {...p} direction="left" />}
transitionDuration={200}
action={
<IconButton
size="small"
color="inherit"
onClick={() => setMessage("")}
>
<Close fontSize="small" />
</IconButton>
}
/>
),
[message]
);
const instance = (Object.fromEntries(
(["info", "error", "success"] as const).map((item) => [
item,
(msg: string) => {
setLevel(item);
setMessage(msg);
},
])
) as unknown) as NoticeInstance;
return [instance, element] as const;
};
export default useNotice;