diff --git a/src-tauri/src/cmds.rs b/src-tauri/src/cmds.rs index 1e24371..d4929ac 100644 --- a/src-tauri/src/cmds.rs +++ b/src-tauri/src/cmds.rs @@ -8,7 +8,7 @@ use crate::{ use anyhow::Result; use serde_yaml::Mapping; use std::{path::PathBuf, process::Command}; -use tauri::{api, State}; +use tauri::{api, Manager, State}; /// get all profiles from `profiles.yaml` #[tauri::command] @@ -100,6 +100,43 @@ pub fn select_profile( wrap_err!(clash.activate(&profiles)) } +/// change the profile chain +#[tauri::command] +pub fn change_profile_chain( + chain: Option>, + app_handle: tauri::AppHandle, + clash_state: State<'_, ClashState>, + profiles_state: State<'_, ProfilesState>, +) -> Result<(), String> { + let clash = clash_state.0.lock().unwrap(); + let mut profiles = profiles_state.0.lock().unwrap(); + + profiles.put_chain(chain); + + app_handle + .get_window("main") + .map(|win| wrap_err!(clash.activate_enhanced(&profiles, win, false))); + + Ok(()) +} + +/// manually exec enhanced profile +#[tauri::command] +pub fn enhance_profiles( + app_handle: tauri::AppHandle, + clash_state: State<'_, ClashState>, + profiles_state: State<'_, ProfilesState>, +) -> Result<(), String> { + let clash = clash_state.0.lock().unwrap(); + let profiles = profiles_state.0.lock().unwrap(); + + app_handle + .get_window("main") + .map(|win| wrap_err!(clash.activate_enhanced(&profiles, win, false))); + + Ok(()) +} + /// delete profile item #[tauri::command] pub fn delete_profile( diff --git a/src-tauri/src/core/clash.rs b/src-tauri/src/core/clash.rs index eb94325..8df5daa 100644 --- a/src-tauri/src/core/clash.rs +++ b/src-tauri/src/core/clash.rs @@ -1,4 +1,4 @@ -use super::{Profiles, Verge}; +use super::{PrfEnhancedResult, Profiles, Verge}; use crate::utils::{config, dirs, help}; use anyhow::{bail, Result}; use reqwest::header::HeaderMap; @@ -260,6 +260,7 @@ impl Clash { let mut data = HashMap::new(); data.insert("path", temp_path.as_os_str().to_str().unwrap()); + // retry 5 times for _ in 0..5 { match reqwest::ClientBuilder::new().no_proxy().build() { Ok(client) => match client @@ -269,11 +270,18 @@ impl Clash { .send() .await { - Ok(_) => break, + Ok(resp) => { + if resp.status() != 204 { + log::error!("failed to activate clash for status \"{}\"", resp.status()); + } + // do not retry + break; + } Err(err) => log::error!("failed to activate for `{err}`"), }, Err(err) => log::error!("failed to activate for `{err}`"), } + sleep(Duration::from_millis(500)).await; } }); @@ -294,29 +302,43 @@ impl Clash { } /// enhanced profiles mode - pub fn activate_enhanced(&self, profiles: &Profiles, win: tauri::Window) -> Result<()> { + pub fn activate_enhanced( + &self, + profiles: &Profiles, + win: tauri::Window, + delay: bool, + ) -> Result<()> { let event_name = help::get_uid("e"); - let event_name = format!("script-cb-{event_name}"); + let event_name = format!("enhanced-cb-{event_name}"); let info = self.info.clone(); let mut config = self.config.clone(); // generate the payload - let payload = profiles.gen_enhanced()?; + let payload = profiles.gen_enhanced(event_name.clone())?; win.once(&event_name, move |event| { if let Some(result) = event.payload() { - let gen_map: Mapping = serde_json::from_str(result).unwrap(); + let result: PrfEnhancedResult = serde_json::from_str(result).unwrap(); - for (key, value) in gen_map.into_iter() { - config.insert(key, value); + if let Some(data) = result.data { + for (key, value) in data.into_iter() { + config.insert(key, value); + } + Self::_activate(info, config).unwrap(); } - Self::_activate(info, config).unwrap(); + + log::info!("profile enhanced status {}", result.status); + + result.error.map(|error| log::error!("{error}")); } }); tauri::async_runtime::spawn(async move { - sleep(Duration::from_secs(5)).await; + // wait the window setup during resolve app + if delay { + sleep(Duration::from_secs(2)).await; + } win.emit("script-handler", payload).unwrap(); }); diff --git a/src-tauri/src/core/profiles.rs b/src-tauri/src/core/profiles.rs index a80d9ac..7fa8058 100644 --- a/src-tauri/src/core/profiles.rs +++ b/src-tauri/src/core/profiles.rs @@ -314,6 +314,11 @@ impl Profiles { bail!("invalid uid \"{uid}\""); } + /// just change the `chain` + pub fn put_chain(&mut self, chain: Option>) { + self.chain = chain; + } + /// find the item by the uid pub fn get_item(&self, uid: &String) -> Result<&PrfItem> { if self.items.is_some() { @@ -519,7 +524,7 @@ impl Profiles { } /// gen the enhanced profiles - pub fn gen_enhanced(&self) -> Result { + pub fn gen_enhanced(&self, callback: String) -> Result { let current = self.gen_activate()?; let chain = match self.chain.as_ref() { @@ -535,7 +540,11 @@ impl Profiles { None => vec![], }; - Ok(PrfEnhanced { current, chain }) + Ok(PrfEnhanced { + current, + chain, + callback, + }) } } @@ -544,6 +553,17 @@ pub struct PrfEnhanced { current: Mapping, chain: Vec, + + callback: String, +} + +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub struct PrfEnhancedResult { + pub data: Option, + + pub status: String, + + pub error: Option, } #[derive(Default, Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d06b944..5cbcd4f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -94,6 +94,8 @@ fn main() -> std::io::Result<()> { cmds::select_profile, cmds::get_profiles, cmds::sync_profiles, + cmds::enhance_profiles, + cmds::change_profile_chain ]); #[cfg(target_os = "macos")] diff --git a/src-tauri/src/utils/resolve.rs b/src-tauri/src/utils/resolve.rs index e9f3c14..1dbb263 100644 --- a/src-tauri/src/utils/resolve.rs +++ b/src-tauri/src/utils/resolve.rs @@ -26,9 +26,10 @@ pub fn resolve_setup(app: &App) { *profiles = Profiles::read_file(); log_if_err!(clash.activate(&profiles)); - app - .get_window("main") - .map(|win| log_if_err!(clash.activate_enhanced(&profiles, win))); + match app.get_window("main") { + Some(win) => log_if_err!(clash.activate_enhanced(&profiles, win, true)), + None => log::error!("failed to get window for enhanced profiles"), + }; verge.init_sysproxy(clash.info.port.clone()); // enable tun mode diff --git a/src/components/profile/profile-more.tsx b/src/components/profile/profile-more.tsx index 367ac5a..2633129 100644 --- a/src/components/profile/profile-more.tsx +++ b/src/components/profile/profile-more.tsx @@ -1,6 +1,4 @@ import dayjs from "dayjs"; -import { useLockFn } from "ahooks"; -import { useSWRConfig } from "swr"; import { useState } from "react"; import { alpha, @@ -12,7 +10,7 @@ import { Menu, } from "@mui/material"; import { CmdType } from "../../services/types"; -import { deleteProfile, viewProfile } from "../../services/cmds"; +import { viewProfile } from "../../services/cmds"; import relativeTime from "dayjs/plugin/relativeTime"; import ProfileEdit from "./profile-edit"; import Notice from "../base/base-notice"; @@ -37,6 +35,7 @@ interface Props { onDisable: () => void; onMoveTop: () => void; onMoveEnd: () => void; + onDelete: () => void; } // profile enhanced item @@ -48,10 +47,10 @@ const ProfileMore = (props: Props) => { onDisable, onMoveTop, onMoveEnd, + onDelete, } = props; const { type } = itemData; - const { mutate } = useSWRConfig(); const [anchorEl, setAnchorEl] = useState(null); const [position, setPosition] = useState({ left: 0, top: 0 }); const [editOpen, setEditOpen] = useState(false); @@ -70,30 +69,25 @@ const ProfileMore = (props: Props) => { } }; - const onDelete = useLockFn(async () => { + const closeWrapper = (fn: () => void) => () => { setAnchorEl(null); - try { - await deleteProfile(itemData.uid); - mutate("getProfiles"); - } catch (err: any) { - Notice.error(err?.message || err.toString()); - } - }); + return fn(); + }; const enableMenu = [ - { label: "Disable", handler: onDisable }, + { label: "Disable", handler: closeWrapper(onDisable) }, { label: "Edit", handler: onEdit }, { label: "View File", handler: onView }, - { label: "To Top", handler: onMoveTop }, - { label: "To End", handler: onMoveEnd }, - { label: "Delete", handler: onDelete }, + { label: "To Top", handler: closeWrapper(onMoveTop) }, + { label: "To End", handler: closeWrapper(onMoveEnd) }, + { label: "Delete", handler: closeWrapper(onDelete) }, ]; const disableMenu = [ - { label: "Enable", handler: onEnable }, + { label: "Enable", handler: closeWrapper(onEnable) }, { label: "Edit", handler: onEdit }, { label: "View File", handler: onView }, - { label: "Delete", handler: onDelete }, + { label: "Delete", handler: closeWrapper(onDelete) }, ]; const boxStyle = { diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 1557d59..3caee2a 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -1,12 +1,14 @@ import useSWR, { useSWRConfig } from "swr"; -import { useEffect, useMemo, useState } from "react"; import { useLockFn } from "ahooks"; +import { useEffect, useMemo, useState } from "react"; import { Box, Button, Grid, TextField } from "@mui/material"; import { getProfiles, - selectProfile, patchProfile, + deleteProfile, + selectProfile, importProfile, + changeProfileChain, } from "../services/cmds"; import { getProxies, updateProxy } from "../services/api"; import Notice from "../components/base/base-notice"; @@ -25,13 +27,20 @@ const ProfilePage = () => { const { data: profiles = {} } = useSWR("getProfiles", getProfiles); const { regularItems, enhanceItems } = useMemo(() => { - const { items = [] } = profiles; - const regularItems = items.filter((i) => - ["local", "remote"].includes(i.type!) - ); - const enhanceItems = items.filter((i) => - ["merge", "script"].includes(i.type!) - ); + const items = profiles.items || []; + const chain = profiles.chain || []; + + const type1 = ["local", "remote"]; + const type2 = ["merge", "script"]; + + const regularItems = items.filter((i) => type1.includes(i.type!)); + const restItems = items.filter((i) => type2.includes(i.type!)); + + const restMap = Object.fromEntries(restItems.map((i) => [i.uid, i])); + + const enhanceItems = chain + .map((i) => restMap[i]!) + .concat(restItems.filter((i) => !chain.includes(i.uid))); return { regularItems, enhanceItems }; }, [profiles]); @@ -113,10 +122,51 @@ const ProfilePage = () => { } }); - const onEnhanceEnable = useLockFn(async (uid: string) => {}); - const onEnhanceDisable = useLockFn(async (uid: string) => {}); - const onMoveTop = useLockFn(async (uid: string) => {}); - const onMoveEnd = useLockFn(async (uid: string) => {}); + /** enhanced profile mode */ + + const chain = profiles.chain || []; + + const onEnhanceEnable = useLockFn(async (uid: string) => { + if (chain.includes(uid)) return; + + const newChain = [...chain, uid]; + await changeProfileChain(newChain); + mutate("getProfiles", { ...profiles, chain: newChain }, true); + }); + + const onEnhanceDisable = useLockFn(async (uid: string) => { + if (!chain.includes(uid)) return; + + const newChain = chain.filter((i) => i !== uid); + await changeProfileChain(newChain); + mutate("getProfiles", { ...profiles, chain: newChain }, true); + }); + + const onEnhanceDelete = useLockFn(async (uid: string) => { + try { + await onEnhanceDisable(uid); + await deleteProfile(uid); + mutate("getProfiles"); + } catch (err: any) { + Notice.error(err?.message || err.toString()); + } + }); + + const onMoveTop = useLockFn(async (uid: string) => { + if (!chain.includes(uid)) return; + + const newChain = [uid].concat(chain.filter((i) => i !== uid)); + await changeProfileChain(newChain); + mutate("getProfiles", { ...profiles, chain: newChain }, true); + }); + + const onMoveEnd = useLockFn(async (uid: string) => { + if (!chain.includes(uid)) return; + + const newChain = chain.filter((i) => i !== uid).concat([uid]); + await changeProfileChain(newChain); + mutate("getProfiles", { ...profiles, chain: newChain }, true); + }); return ( @@ -164,6 +214,7 @@ const ProfilePage = () => { itemData={item} onEnable={() => onEnhanceEnable(item.uid)} onDisable={() => onEnhanceDisable(item.uid)} + onDelete={() => onEnhanceDelete(item.uid)} onMoveTop={() => onMoveTop(item.uid)} onMoveEnd={() => onMoveEnd(item.uid)} /> diff --git a/src/services/cmds.ts b/src/services/cmds.ts index 1c4db45..057809e 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -9,6 +9,10 @@ export async function syncProfiles() { return invoke("sync_profiles"); } +export async function enhanceProfiles() { + return invoke("enhance_profiles"); +} + export async function createProfile(item: Partial) { return invoke("create_profile", { item }); } @@ -40,8 +44,8 @@ export async function selectProfile(index: string) { return invoke("select_profile", { index }); } -export async function restartSidecar() { - return invoke("restart_sidecar"); +export async function changeProfileChain(chain?: string[]) { + return invoke("change_profile_chain", { chain }); } export async function getClashInfo() { @@ -64,6 +68,10 @@ export async function getSystemProxy() { return invoke("get_sys_proxy"); } +export async function restartSidecar() { + return invoke("restart_sidecar"); +} + export async function killSidecars() { return invoke("kill_sidecars"); } diff --git a/src/services/enhance.ts b/src/services/enhance.ts index 6d42355..7bd9ff7 100644 --- a/src/services/enhance.ts +++ b/src/services/enhance.ts @@ -1,22 +1,98 @@ import { emit, listen } from "@tauri-apps/api/event"; import { CmdType } from "./types"; +function toMerge( + merge: CmdType.ProfileMerge, + data: CmdType.ProfileData +): CmdType.ProfileData { + if (!merge) return data; + + const newData = { ...data }; + + // rules + if (Array.isArray(merge["prepend-rules"])) { + if (!newData.rules) newData.rules = []; + newData.rules.unshift(...merge["prepend-rules"]); + } + if (Array.isArray(merge["append-rules"])) { + if (!newData.rules) newData.rules = []; + newData.rules.push(...merge["append-rules"]); + } + + // proxies + if (Array.isArray(merge["prepend-proxies"])) { + if (!newData.proxies) newData.proxies = []; + newData.proxies.unshift(...merge["prepend-proxies"]); + } + if (Array.isArray(merge["append-proxies"])) { + if (!newData.proxies) newData.proxies = []; + newData.proxies.push(...merge["append-proxies"]); + } + + // proxy-groups + if (Array.isArray(merge["prepend-proxy-groups"])) { + if (!newData["proxy-groups"]) newData["proxy-groups"] = []; + newData["proxy-groups"].unshift(...merge["prepend-proxy-groups"]); + } + if (Array.isArray(merge["append-proxy-groups"])) { + if (!newData["proxy-groups"]) newData["proxy-groups"] = []; + newData["proxy-groups"].push(...merge["append-proxy-groups"]); + } + + return newData; +} + +function toScript( + script: string, + data: CmdType.ProfileData +): Promise { + if (!script) { + throw new Error("miss the main function"); + } + + const paramsName = `__verge${Math.floor(Math.random() * 1000)}`; + const code = `'use strict';${script};return main(${paramsName});`; + const func = new Function(paramsName, code); + return func(data); // support async main function +} + export default function setup() { - listen("script-handler", (event) => { + listen("script-handler", async (event) => { const payload = event.payload as CmdType.EnhancedPayload; console.log(payload); - // setTimeout(() => { - // try { - // const fn = eval(payload.script + "\n\nmixin"); - // console.log(fn); + let pdata = payload.current || {}; - // const result = fn(payload.params || {}); - // console.log("result", result); - // emit(payload.callback, JSON.stringify(result)).catch(console.error); - // } catch (err) { - // console.error(err); - // } - // }, 3000); + for (const each of payload.chain) { + try { + // process script + if (each.item.type === "script") { + pdata = await toScript(each.script!, pdata); + } + + // process merge + else if (each.item.type === "merge") { + pdata = toMerge(each.merge!, pdata); + } + + // invalid type + else { + throw new Error(`invalid enhanced profile type "${each.item.type}"`); + } + + console.log("step", pdata); + } catch (err) { + console.error(err); + } + } + + const result: CmdType.EnhancedResult = { + data: pdata, + status: "success", + }; + + emit(payload.callback, JSON.stringify(result)).catch(console.error); }); + + // enhanceProfiles(); } diff --git a/src/services/types.ts b/src/services/types.ts index e15f76d..1d04a63 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -124,14 +124,32 @@ export namespace CmdType { system_proxy_bypass?: string; } + export type ProfileMerge = Record; + + // partial of the clash config + export type ProfileData = Partial<{ + rules: any[]; + proxies: any[]; + "proxy-groups": any[]; + "proxy-providers": any[]; + "rule-providers": any[]; + }>; + export interface ChainItem { item: ProfileItem; - merge?: object; + merge?: ProfileMerge; script?: string; } export interface EnhancedPayload { chain: ChainItem[]; - current: object; + current: ProfileData; + callback: string; + } + + export interface EnhancedResult { + data: ProfileData; + status: string; + error?: string; } }