Update On Sun Feb 2 19:31:29 CET 2025

This commit is contained in:
github-action[bot]
2025-02-02 19:31:29 +01:00
parent 5fbdd9228c
commit 10c74b79e1
210 changed files with 6548 additions and 8492 deletions

1
.github/update.log vendored
View File

@@ -901,3 +901,4 @@ Update On Wed Jan 29 19:35:42 CET 2025
Update On Thu Jan 30 19:32:29 CET 2025
Update On Fri Jan 31 19:32:11 CET 2025
Update On Sat Feb 1 19:32:16 CET 2025
Update On Sun Feb 2 19:31:20 CET 2025

View File

@@ -46,6 +46,12 @@ export default {
],
},
],
'at-rule-no-deprecated': [
true,
{
ignoreAtRules: ['apply'],
},
],
},
overrides: [
{

File diff suppressed because it is too large Load Diff

View File

@@ -16,12 +16,17 @@ name = "nyanpasu-network-statistic-widget-small"
path = "./src/small.rs"
[dependencies]
eframe = "0.29.1"
egui_extras = { version = "0.29", features = ["all_loaders"] }
eframe = "0.30"
egui_extras = { version = "0.30", features = ["all_loaders"] }
parking_lot = "0.12"
image = { version = "0.25.5", features = ["jpeg", "png"] }
humansize = "2.1.3"
image = { version = "0.25", features = ["jpeg", "png"] }
humansize = "2"
# for svg currentColor replacement
resvg = "0.44.0" # for svg rendering
usvg = "0.44.0" # for svg parsing
csscolorparser = "0.7.0" # for color conversion
resvg = "0.44" # for svg rendering
usvg = "0.44" # for svg parsing
csscolorparser = "0.7" # for color conversion
ipc-channel = "0.19" # for IPC between the Widget process and the GUI process
serde = { version = "1", features = ["derive"] }
anyhow = "1"
specta = { version = "=2.0.0-rc.22", features = ["serde"] }
clap = { version = "4", features = ["derive"] }

View File

@@ -0,0 +1,67 @@
pub use ipc_channel::ipc::IpcSender;
use ipc_channel::ipc::{self, IpcReceiver};
use crate::widget::network_statistic_large::LogoPreset;
#[derive(Debug, Default, serde::Deserialize, serde::Serialize)]
pub struct StatisticMessage {
pub download_total: u64,
pub upload_total: u64,
pub download_speed: u64,
pub upload_speed: u64,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub enum Message {
Stop,
UpdateStatistic(StatisticMessage),
UpdateLogo(LogoPreset),
}
pub struct IPCServer {
oneshot_server: Option<ipc::IpcOneShotServer<IpcSender<Message>>>,
tx: Option<IpcSender<Message>>,
}
impl IPCServer {
pub fn is_connected(&self) -> bool {
self.tx.is_some()
}
pub fn connect(&mut self) -> anyhow::Result<()> {
if self.oneshot_server.is_none() {
anyhow::bail!("IPC server is already initialized");
}
let (_, tx) = self.oneshot_server.take().unwrap().accept()?;
self.tx = Some(tx);
Ok(())
}
pub fn into_tx(self) -> Option<IpcSender<Message>> {
self.tx
}
}
pub fn create_ipc_server() -> anyhow::Result<(IPCServer, String)> {
let (oneshot_server, oneshot_server_name) = ipc::IpcOneShotServer::new()?;
Ok((
IPCServer {
oneshot_server: Some(oneshot_server),
tx: None,
},
oneshot_server_name,
))
}
pub(crate) fn setup_ipc_receiver(name: &str) -> anyhow::Result<IpcReceiver<Message>> {
let oneshot_sender: IpcSender<IpcSender<Message>> = ipc::IpcSender::connect(name.to_string())?;
let (tx, rx) = ipc::channel()?;
oneshot_sender.send(tx)?;
Ok(rx)
}
pub(crate) fn setup_ipc_receiver_with_env() -> anyhow::Result<IpcReceiver<Message>> {
let name = std::env::var("NYANPASU_EGUI_IPC_SERVER")?;
setup_ipc_receiver(&name)
}

View File

@@ -1,2 +1,5 @@
#![feature(trait_alias)]
pub mod ipc;
mod utils;
pub mod widget;

View File

@@ -3,3 +3,35 @@ pub mod network_statistic_small;
pub use network_statistic_large::NyanpasuNetworkStatisticLargeWidget;
pub use network_statistic_small::NyanpasuNetworkStatisticSmallWidget;
// pub fn launch_widget<'app, T: Send + Sync + Sized, A: EframeAppCreator<'app, T>>(
// name: &str,
// opts: eframe::NativeOptions,
// creator: A,
// ) -> std::io::Result<Receiver<WidgetEvent<T>>> {
// let (tx, rx) = mpsc::channel();
// }
#[derive(
Debug,
serde::Serialize,
serde::Deserialize,
specta::Type,
Copy,
Clone,
PartialEq,
Eq,
clap::ValueEnum,
)]
#[serde(rename_all = "snake_case")]
pub enum StatisticWidgetVariant {
Large,
Small,
}
pub fn start_statistic_widget(size: StatisticWidgetVariant) -> eframe::Result {
match size {
StatisticWidgetVariant::Large => NyanpasuNetworkStatisticLargeWidget::run(),
StatisticWidgetVariant::Small => NyanpasuNetworkStatisticSmallWidget::run(),
}
}

View File

@@ -1,10 +1,14 @@
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::OnceLock;
use crate::ipc::Message;
use crate::utils::svg::{render_svg_with_current_color_replace, SvgExt};
use eframe::egui::{
self, style::Selection, Color32, Id, Image, Layout, Margin, Rounding, Sense, Stroke, Style,
TextureOptions, Theme, Vec2, ViewportCommand, Visuals,
};
use parking_lot::RwLock;
// Presets
const STATUS_ICON_CONTAINER_WIDTH: f32 = 20.0;
@@ -42,7 +46,9 @@ fn setup_fonts(ctx: &egui::Context) {
fonts.font_data.insert(
"Inter".to_owned(),
egui::FontData::from_static(include_bytes!("../../assets/Inter-Regular.ttf")),
Arc::new(egui::FontData::from_static(include_bytes!(
"../../assets/Inter-Regular.ttf"
))),
);
fonts
@@ -85,7 +91,8 @@ fn use_dark_purple_accent(style: &mut Style) {
};
}
#[derive(Debug, Default, Clone, Copy)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum LogoPreset {
#[default]
Default,
@@ -93,30 +100,20 @@ pub enum LogoPreset {
Tun,
}
#[derive(Debug, Default)]
pub struct StatisticMessage {
download_total: u64,
upload_total: u64,
download_speed: u64,
upload_speed: u64,
}
#[derive(Debug)]
pub enum Message {
UpdateStatistic(StatisticMessage),
UpdateLogo(LogoPreset),
}
#[derive(Debug)]
pub struct NyanpasuNetworkStatisticLargeWidget {
pub struct NyanpasuNetworkStatisticLargeWidgetInner {
// data fields
logo_preset: LogoPreset,
download_total: u64,
upload_total: u64,
download_speed: u64,
upload_speed: u64,
// eframe ctx
egui_ctx: OnceLock<egui::Context>,
}
impl Default for NyanpasuNetworkStatisticLargeWidget {
impl Default for NyanpasuNetworkStatisticLargeWidgetInner {
fn default() -> Self {
Self {
logo_preset: LogoPreset::Default,
@@ -124,6 +121,22 @@ impl Default for NyanpasuNetworkStatisticLargeWidget {
upload_total: 0,
download_speed: 0,
upload_speed: 0,
egui_ctx: OnceLock::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct NyanpasuNetworkStatisticLargeWidget {
inner: Arc<RwLock<NyanpasuNetworkStatisticLargeWidgetInner>>,
}
impl Default for NyanpasuNetworkStatisticLargeWidget {
fn default() -> Self {
Self {
inner: Arc::new(RwLock::new(
NyanpasuNetworkStatisticLargeWidgetInner::default(),
)),
}
}
}
@@ -134,23 +147,72 @@ impl NyanpasuNetworkStatisticLargeWidget {
setup_fonts(&cc.egui_ctx);
setup_custom_style(&cc.egui_ctx);
egui_extras::install_image_loaders(&cc.egui_ctx);
Self::default()
let rx = crate::ipc::setup_ipc_receiver_with_env().unwrap();
let widget = Self::default();
let this = widget.clone();
std::thread::spawn(move || loop {
match rx.recv() {
Ok(msg) => {
let _ = this.handle_message(msg);
}
Err(e) => {
eprintln!("Failed to receive message: {}", e);
if matches!(
e,
ipc_channel::ipc::IpcError::Disconnected
| ipc_channel::ipc::IpcError::Io(_)
) {
let _ = this.handle_message(Message::Stop);
break;
}
}
}
});
widget
}
pub fn handle_message(&mut self, msg: Message) -> bool {
pub fn run() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([206.0, 60.0])
.with_decorations(false)
.with_transparent(true)
.with_always_on_top()
.with_drag_and_drop(true)
.with_resizable(false)
.with_taskbar(false),
..Default::default()
};
eframe::run_native(
"Nyanpasu Network Statistic Widget",
options,
Box::new(|cc| Ok(Box::new(NyanpasuNetworkStatisticLargeWidget::new(cc)))),
)
}
pub fn handle_message(&self, msg: Message) -> anyhow::Result<()> {
let mut this = self.inner.write();
match msg {
Message::UpdateStatistic(statistic) => {
self.download_total = statistic.download_total;
self.upload_total = statistic.upload_total;
self.download_speed = statistic.download_speed;
self.upload_speed = statistic.upload_speed;
true
this.download_total = statistic.download_total;
this.upload_total = statistic.upload_total;
this.download_speed = statistic.download_speed;
this.upload_speed = statistic.upload_speed;
}
Message::UpdateLogo(logo_preset) => {
self.logo_preset = logo_preset;
true
this.logo_preset = logo_preset;
}
Message::Stop => match this.egui_ctx.get() {
Some(ctx) => {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
None => {
eprintln!("Failed to close the widget: eframe context is not initialized");
std::process::exit(1);
}
},
}
Ok(())
}
}
@@ -160,6 +222,11 @@ impl eframe::App for NyanpasuNetworkStatisticLargeWidget {
}
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// setup ctx
let egui_ctx = ctx.clone();
let this = self.inner.read();
let _ = this.egui_ctx.get_or_init(move || egui_ctx);
let visuals = &ctx.style().visuals;
egui::CentralPanel::default()
@@ -229,7 +296,7 @@ impl eframe::App for NyanpasuNetworkStatisticLargeWidget {
let width = ui.available_width();
let height = ui.available_height();
ui.allocate_ui_with_layout(egui::Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
ui.label(humansize::format_size(self.download_total, humansize::DECIMAL));
ui.label(humansize::format_size(this.download_total, humansize::DECIMAL));
});
});
});
@@ -262,7 +329,7 @@ impl eframe::App for NyanpasuNetworkStatisticLargeWidget {
let width = ui.available_width();
let height = ui.available_height();
ui.allocate_ui_with_layout(egui::Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
ui.label(format!("{}/s", humansize::format_size(self.download_speed, humansize::DECIMAL)));
ui.label(format!("{}/s", humansize::format_size(this.download_speed, humansize::DECIMAL)));
});
});
})
@@ -300,7 +367,7 @@ impl eframe::App for NyanpasuNetworkStatisticLargeWidget {
let width = ui.available_width();
let height = ui.available_height();
ui.allocate_ui_with_layout(egui::Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
ui.label(humansize::format_size(self.upload_total, humansize::DECIMAL));
ui.label(humansize::format_size(this.upload_total, humansize::DECIMAL));
});
});
});
@@ -333,7 +400,7 @@ impl eframe::App for NyanpasuNetworkStatisticLargeWidget {
let width = ui.available_width();
let height = ui.available_height();
ui.allocate_ui_with_layout(egui::Vec2::new(width, height), Layout::centered_and_justified(egui::Direction::TopDown), |ui| {
ui.label(format!("{}/s", humansize::format_size(self.upload_speed, humansize::DECIMAL)));
ui.label(format!("{}/s", humansize::format_size(this.upload_speed, humansize::DECIMAL)));
});
});
})

View File

@@ -1,9 +1,14 @@
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::OnceLock;
use eframe::egui::{
self, include_image, style::Selection, Color32, Id, Image, Layout, Margin, RichText, Rounding,
Sense, Stroke, Style, Theme, Vec2, ViewportCommand, Visuals, WidgetText,
};
use parking_lot::RwLock;
use crate::ipc::Message;
// Presets
const STATUS_ICON_CONTAINER_WIDTH: f32 = 20.0;
@@ -36,7 +41,9 @@ fn setup_fonts(ctx: &egui::Context) {
fonts.font_data.insert(
"Inter".to_owned(),
egui::FontData::from_static(include_bytes!("../../assets/Inter-Regular.ttf")),
Arc::new(egui::FontData::from_static(include_bytes!(
"../../assets/Inter-Regular.ttf"
))),
);
fonts
@@ -79,19 +86,105 @@ fn use_dark_purple_accent(style: &mut Style) {
};
}
#[derive(Clone)]
pub struct NyanpasuNetworkStatisticSmallWidget {
demo_size: u64,
state: Arc<RwLock<NyanpasuNetworkStatisticSmallWidgetState>>,
}
impl Default for NyanpasuNetworkStatisticSmallWidget {
fn default() -> Self {
Self {
state: Arc::new(RwLock::new(
NyanpasuNetworkStatisticSmallWidgetState::default(),
)),
}
}
}
#[derive(Default)]
struct NyanpasuNetworkStatisticSmallWidgetState {
// data fields
// download_total: u64,
// upload_total: u64,
download_speed: u64,
upload_speed: u64,
// eframe ctx
egui_ctx: OnceLock<egui::Context>,
}
impl NyanpasuNetworkStatisticSmallWidget {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
cc.egui_ctx.set_visuals(Visuals::light());
cc.egui_ctx.set_visuals(Visuals::dark());
setup_fonts(&cc.egui_ctx);
setup_custom_style(&cc.egui_ctx);
egui_extras::install_image_loaders(&cc.egui_ctx);
Self {
demo_size: 100_000_000,
let rx = crate::ipc::setup_ipc_receiver_with_env().unwrap();
let widget = Self::default();
let this = widget.clone();
std::thread::spawn(move || loop {
match rx.recv() {
Ok(msg) => {
let _ = this.handle_message(msg);
}
Err(e) => {
eprintln!("Failed to receive message: {}", e);
if matches!(
e,
ipc_channel::ipc::IpcError::Disconnected
| ipc_channel::ipc::IpcError::Io(_)
) {
let _ = this.handle_message(Message::Stop);
break;
}
}
}
});
widget
}
pub fn run() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([206.0, 60.0])
.with_decorations(false)
.with_transparent(true)
.with_always_on_top()
.with_drag_and_drop(true)
.with_resizable(false)
.with_taskbar(false),
..Default::default()
};
eframe::run_native(
"Nyanpasu Network Statistic Widget",
options,
Box::new(|cc| Ok(Box::new(NyanpasuNetworkStatisticSmallWidget::new(cc)))),
)
}
pub fn handle_message(&self, msg: Message) -> anyhow::Result<()> {
let mut this = self.state.write();
match msg {
Message::UpdateStatistic(statistic) => {
// this.download_total = statistic.download_total;
// this.upload_total = statistic.upload_total;
this.download_speed = statistic.download_speed;
this.upload_speed = statistic.upload_speed;
}
Message::Stop => match this.egui_ctx.get() {
Some(ctx) => {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
None => {
eprintln!("Failed to close the widget: eframe context is not initialized");
std::process::exit(1);
}
},
_ => {
eprintln!("Unsupported message: {:?}", msg);
}
}
Ok(())
}
}
@@ -102,6 +195,9 @@ impl eframe::App for NyanpasuNetworkStatisticSmallWidget {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
let visuals = &ctx.style().visuals;
let egui_ctx = ctx.clone();
let this = self.state.read();
let _ = this.egui_ctx.get_or_init(move || egui_ctx);
egui::CentralPanel::default()
.frame(
@@ -153,7 +249,10 @@ impl eframe::App for NyanpasuNetworkStatisticSmallWidget {
ui.label(
WidgetText::from(RichText::new(format!(
"{}/s",
humansize::format_size(self.demo_size, humansize::DECIMAL)
humansize::format_size(
this.upload_speed,
humansize::DECIMAL
)
)))
.color(LIGHT_MODE_TEXT_COLOR),
);
@@ -166,7 +265,10 @@ impl eframe::App for NyanpasuNetworkStatisticSmallWidget {
ui.label(
WidgetText::from(RichText::new(format!(
"{}/s",
humansize::format_size(self.demo_size, humansize::DECIMAL)
humansize::format_size(
this.download_speed,
humansize::DECIMAL
)
)))
.color(LIGHT_MODE_TEXT_COLOR),
);

View File

@@ -16,7 +16,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.0.1", features = [] }
serde = "1"
simd-json = "0.14.1"
serde_json = { version = "1.0", features = ["preserve_order"] }
chrono = "0.4"
rustc_version = "0.4"
semver = "1.0"
@@ -31,10 +31,12 @@ nyanpasu-macro = { path = "../nyanpasu-macro" }
nyanpasu-utils = { git = "https://github.com/libnyanpasu/nyanpasu-utils.git", features = [
"specta",
] }
nyanpasu-egui = { path = "../nyanpasu-egui" }
# Common Utilities
tokio = { version = "1", features = ["full"] }
futures = "0.3"
futures-util = "0.3"
glob = "0.3.1"
timeago = "0.4"
humansize = "2.1.3"
@@ -47,13 +49,14 @@ async-trait = "0.1.77"
dyn-clone = "1.0.16"
thiserror = { workspace = true }
parking_lot = { version = "0.12.1" }
itertools = "0.14" # sweet iterator utilities
rayon = "1.10" # for iterator parallel processing
ambassador = "0.4.1" # for trait delegation
derive_builder = "0.20" # for builder pattern
strum = { version = "0.26", features = ["derive"] } # for enum string conversion
atomic_enum = "0.3.0" # for atomic enum
enumflags2 = "0.7" # for enum flags
itertools = "0.14" # sweet iterator utilities
rayon = "1.10" # for iterator parallel processing
ambassador = "0.4.1" # for trait delegation
derive_builder = "0.20" # for builder pattern
strum = { version = "0.26", features = ["derive"] } # for enum string conversion
atomic_enum = "0.3.0" # for atomic enum
enumflags2 = "0.7" # for enum flags
backon = { version = "1.0.1", features = ["tokio-sleep"] } # for backoff retry
# Data Structures
dashmap = "6"
@@ -61,7 +64,7 @@ indexmap = { version = "2.2.3", features = ["serde"] }
bimap = "0.6.3"
# Terminal Utilities
ansi-str = "0.8" # for ansi str stripped
ansi-str = "0.9" # for ansi str stripped
ctrlc = "3.4.2"
colored = "3"
clap = { version = "4.5.4", features = ["derive"] }
@@ -81,17 +84,17 @@ axum = "0.8"
url = "2"
mime = "0.3"
reqwest = { version = "0.12", features = ["json", "stream"] }
tokio-tungstenite = "0.26.1"
urlencoding = "2.1"
port_scanner = "0.1.5"
sysproxy = { git = "https://github.com/libnyanpasu/sysproxy-rs.git", version = "0.3" }
backon = { version = "1.0.1", features = ["tokio-sleep"] }
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_json = { version = "1.0", features = ["preserve_order"] }
serde_yaml = { version = "0.10", package = "serde_yaml_ng", branch = "feat/specta", git = "https://github.com/libnyanpasu/serde-yaml-ng.git", features = [
"specta",
] }
simd-json = "0.14.1"
bincode = "1"
bytes = { version = "1", features = ["serde"] }
semver = "1.0"

View File

@@ -20,12 +20,12 @@ struct GitInfo {
fn main() {
let version: String = if let Ok(true) = exists("../../package.json") {
let mut raw = read("../../package.json").unwrap();
let pkg_json: PackageJson = simd_json::from_slice(&mut raw).unwrap();
let raw = read("../../package.json").unwrap();
let pkg_json: PackageJson = serde_json::from_slice(&raw).unwrap();
pkg_json.version
} else {
let mut raw = read("./tauri.conf.json").unwrap(); // TODO: fix it when windows arm64 need it
let tauri_json: PackageJson = simd_json::from_slice(&mut raw).unwrap();
let raw = read("./tauri.conf.json").unwrap(); // TODO: fix it when windows arm64 need it
let tauri_json: PackageJson = serde_json::from_slice(&raw).unwrap();
tauri_json.version
};
let version = semver::Version::parse(&version).unwrap();
@@ -34,8 +34,8 @@ fn main() {
// Git Information
let (commit_hash, commit_author, commit_date) = if let Ok(true) = exists("./tmp/git-info.json")
{
let mut git_info = read("./tmp/git-info.json").unwrap();
let git_info: GitInfo = simd_json::from_slice(&mut git_info).unwrap();
let git_info = read("./tmp/git-info.json").unwrap();
let git_info: GitInfo = serde_json::from_slice(&git_info).unwrap();
(git_info.hash, git_info.author, git_info.time)
} else {
let output = Command::new("git")

File diff suppressed because one or more lines are too long

View File

@@ -1724,6 +1724,14 @@
"name"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -1731,14 +1739,6 @@
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
@@ -1750,6 +1750,10 @@
"sidecar"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -1758,10 +1762,6 @@
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
@@ -1784,6 +1784,14 @@
"name"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -1791,14 +1799,6 @@
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
@@ -1810,6 +1810,10 @@
"sidecar"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -1818,10 +1822,6 @@
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
@@ -5503,34 +5503,6 @@
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
},
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
}
},
"additionalProperties": false
}
]
},
"ShellScopeEntryAllowedArgs": {
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
"anyOf": [
@@ -5546,6 +5518,34 @@
}
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
},
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
}
]
}
}
}

View File

@@ -1724,6 +1724,14 @@
"name"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -1731,14 +1739,6 @@
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
@@ -1750,6 +1750,10 @@
"sidecar"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -1758,10 +1762,6 @@
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
@@ -1784,6 +1784,14 @@
"name"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -1791,14 +1799,6 @@
"$ref": "#/definitions/ShellScopeEntryAllowedArgs"
}
]
},
"cmd": {
"description": "The command name. It can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.",
"type": "string"
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
}
},
"additionalProperties": false
@@ -1810,6 +1810,10 @@
"sidecar"
],
"properties": {
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"args": {
"description": "The allowed arguments for the command execution.",
"allOf": [
@@ -1818,10 +1822,6 @@
}
]
},
"name": {
"description": "The name for this allowed shell command configuration.\n\nThis name will be used inside of the webview API to call this command along with any specified arguments.",
"type": "string"
},
"sidecar": {
"description": "If this command is a sidecar command.",
"type": "boolean"
@@ -5503,34 +5503,6 @@
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
},
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
}
},
"additionalProperties": false
}
]
},
"ShellScopeEntryAllowedArgs": {
"description": "A set of command arguments allowed to be executed by the webview API.\n\nA value of `true` will allow any arguments to be passed to the command. `false` will disable all arguments. A list of [`ShellScopeEntryAllowedArg`] will set those arguments as the only valid arguments to be passed to the attached command configuration.",
"anyOf": [
@@ -5546,6 +5518,34 @@
}
}
]
},
"ShellScopeEntryAllowedArg": {
"description": "A command argument allowed to be executed by the webview API.",
"anyOf": [
{
"description": "A non-configurable argument that is passed to the command in the order it was specified.",
"type": "string"
},
{
"description": "A variable that is set while calling the command from the webview API.",
"type": "object",
"required": [
"validator"
],
"properties": {
"validator": {
"description": "[regex] validator to require passed values to conform to an expected input.\n\nThis will require the argument value passed to this variable to match the `validator` regex before it will be executed.\n\nThe regex string is by default surrounded by `^...$` to match the full string. For example the `https?://\\w+` regex would be registered as `^https?://\\w+$`.\n\n[regex]: <https://docs.rs/regex/latest/regex/#syntax>",
"type": "string"
},
"raw": {
"description": "Marks the validator as a raw regex, meaning the plugin should not make any modification at runtime.\n\nThis means the regex will not match on the entire string by default, which might be exploited if your regex allow unexpected input to be considered valid. When using this option, make sure your regex is correct.",
"default": false,
"type": "boolean"
}
},
"additionalProperties": false
}
]
}
}
}

View File

@@ -1,12 +1,12 @@
use std::str::FromStr;
use crate::utils;
use anyhow::Ok;
use clap::{Parser, Subcommand};
use migrate::MigrateOpts;
use nyanpasu_egui::widget::StatisticWidgetVariant;
use tauri::utils::platform::current_exe;
use crate::utils;
mod migrate;
#[derive(Parser, Debug)]
@@ -38,6 +38,8 @@ enum Commands {
},
/// Show a panic dialog while the application is enter panic handler.
PanicDialog { message: String },
/// Launch the Widget with the specified name.
StatisticWidget { variant: StatisticWidgetVariant },
}
struct DelayedExitGuard;
@@ -92,6 +94,10 @@ pub fn parse() -> anyhow::Result<()> {
Commands::PanicDialog { message } => {
crate::utils::dialog::panic_dialog(message);
}
Commands::StatisticWidget { variant } => {
nyanpasu_egui::widget::start_statistic_widget(*variant)
.expect("Failed to start statistic widget");
}
}
drop(guard);
std::process::exit(0);

View File

@@ -8,9 +8,11 @@ use specta::Type;
mod clash_strategy;
pub mod logging;
mod widget;
pub use self::clash_strategy::{ClashStrategy, ExternalControllerPortStrategy};
pub use logging::LoggingLevel;
pub use widget::NetworkStatisticWidgetConfig;
// TODO: when support sing-box, remove this struct
#[bitflags]
@@ -132,6 +134,7 @@ impl AsRef<str> for TunStack {
/// ### `verge.yaml` schema
#[derive(Default, Debug, Clone, Deserialize, Serialize, VergePatch, specta::Type)]
#[verge(patch_fn = "patch_config")]
// TODO: use new managedState and builder pattern instead
pub struct IVerge {
/// app listening port for app singleton
pub app_singleton_port: Option<u16>,
@@ -248,6 +251,10 @@ pub struct IVerge {
/// Tun 堆栈选择
/// TODO: 弃用此字段,转移到 clash config 里
pub tun_stack: Option<TunStack>,
/// 是否启用网络统计信息浮窗
#[serde(skip_serializing_if = "Option::is_none")]
pub network_statistic_widget: Option<NetworkStatisticWidgetConfig>,
}
#[derive(Default, Debug, Clone, Deserialize, Serialize, Type)]

View File

@@ -0,0 +1,12 @@
use nyanpasu_egui::widget::StatisticWidgetVariant;
use serde::{Deserialize, Serialize};
use specta::Type;
#[derive(Debug, Default, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Type)]
#[serde(rename_all = "snake_case")]
#[serde(tag = "kind", content = "value")]
pub enum NetworkStatisticWidgetConfig {
#[default]
Disabled,
Enabled(StatisticWidgetVariant),
}

View File

@@ -0,0 +1,92 @@
use crate::config::profile::item_type::ProfileItemType;
use super::item::{
LocalProfileBuilder, MergeProfileBuilder, RemoteProfileBuilder, ScriptProfileBuilder,
};
use serde::{de::Visitor, Deserialize, Deserializer, Serialize};
#[derive(Debug, Serialize, specta::Type)]
#[serde(untagged)]
pub enum ProfileBuilder {
Remote(RemoteProfileBuilder),
Local(LocalProfileBuilder),
Merge(MergeProfileBuilder),
Script(ScriptProfileBuilder),
}
impl<'de> Deserialize<'de> for ProfileBuilder {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct ProfileBuilderVisitor;
impl<'de> Visitor<'de> for ProfileBuilderVisitor {
type Value = ProfileBuilder;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("expecting a profile builder, possible values a map with a key of `type` and a value of `remote`, `local`, `merge`, or `script`")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: serde::de::MapAccess<'de>,
{
let mut mapping: serde_json::Map<String, serde_json::Value> =
serde_json::Map::new();
let mut type_field = None;
while let Some((key, value)) = map.next_entry::<String, serde_json::Value>()? {
if "type" == key.as_str() {
tracing::debug!("type field: {:#?}", value);
type_field =
Some(ProfileItemType::deserialize(value.clone()).map_err(|err| {
serde::de::Error::custom(format!(
"failed to deserialize profile builder type: {}",
err
))
})?);
}
mapping.insert(key, value);
}
let type_field =
type_field.ok_or_else(|| serde::de::Error::missing_field("type"))?;
match type_field {
ProfileItemType::Remote => RemoteProfileBuilder::deserialize(mapping)
.map(ProfileBuilder::Remote)
.map_err(|err| {
serde::de::Error::custom(format!(
"failed to deserialize remote profile builder: {}",
err
))
}),
ProfileItemType::Local => LocalProfileBuilder::deserialize(mapping)
.map(ProfileBuilder::Local)
.map_err(|err| {
serde::de::Error::custom(format!(
"failed to deserialize local profile builder: {}",
err
))
}),
ProfileItemType::Merge => MergeProfileBuilder::deserialize(mapping)
.map(ProfileBuilder::Merge)
.map_err(|err| {
serde::de::Error::custom(format!(
"failed to deserialize merge profile builder: {}",
err
))
}),
ProfileItemType::Script(_) => ScriptProfileBuilder::deserialize(mapping)
.map(ProfileBuilder::Script)
.map_err(|err| {
serde::de::Error::custom(format!(
"failed to deserialize script profile builder: {}",
err
))
}),
}
}
}
deserializer.deserialize_map(ProfileBuilderVisitor)
}
}

View File

@@ -94,6 +94,7 @@ pub trait ProfileCleanup: ProfileHelper {
#[delegate(ProfileSharedSetter)]
#[delegate(ProfileSharedGetter)]
#[delegate(ProfileFileIo)]
#[specta(untagged)]
pub enum Profile {
Remote(RemoteProfile),
Local(LocalProfile),

View File

@@ -1,4 +1,6 @@
pub mod builder;
pub mod item;
pub mod item_type;
pub mod profiles;
pub use builder::ProfileBuilder;
use item::deserialize_single_or_vec;

View File

@@ -1,8 +1,6 @@
use super::{
item::{
prelude::*, LocalProfileBuilder, MergeProfileBuilder, Profile, RemoteProfileBuilder,
ScriptProfileBuilder,
},
builder::ProfileBuilder,
item::{prelude::*, Profile},
item_type::ProfileUid,
};
use crate::utils::{dirs, help};
@@ -16,14 +14,6 @@ use serde_yaml::Mapping;
use std::borrow::Borrow;
use tracing_attributes::instrument;
#[derive(Debug, Serialize, Deserialize, specta::Type)]
pub enum ProfileKind {
Remote(RemoteProfileBuilder),
Local(LocalProfileBuilder),
Merge(MergeProfileBuilder),
Script(ScriptProfileBuilder),
}
/// Define the `profiles.yaml` schema
#[derive(Debug, Clone, Deserialize, Serialize, Builder, BuilderUpdate, specta::Type)]
#[builder(derive(Serialize, Deserialize, specta::Type))]
@@ -140,7 +130,7 @@ impl Profiles {
/// update the item value
#[instrument]
pub fn patch_item(&mut self, uid: String, patch: ProfileKind) -> Result<()> {
pub fn patch_item(&mut self, uid: String, patch: ProfileBuilder) -> Result<()> {
tracing::debug!("patch item: {uid} with {patch:?}");
let item = self
@@ -154,10 +144,10 @@ impl Profiles {
tracing::debug!("patch item: {item:?}");
match (item, patch) {
(Profile::Remote(item), ProfileKind::Remote(builder)) => item.apply(builder),
(Profile::Local(item), ProfileKind::Local(builder)) => item.apply(builder),
(Profile::Merge(item), ProfileKind::Merge(builder)) => item.apply(builder),
(Profile::Script(item), ProfileKind::Script(builder)) => item.apply(builder),
(Profile::Remote(item), ProfileBuilder::Remote(builder)) => item.apply(builder),
(Profile::Local(item), ProfileBuilder::Local(builder)) => item.apply(builder),
(Profile::Merge(item), ProfileBuilder::Merge(builder)) => item.apply(builder),
(Profile::Script(item), ProfileBuilder::Script(builder)) => item.apply(builder),
_ => bail!("profile type mismatch when patching"),
};

View File

@@ -49,3 +49,7 @@ static APP_HANDLE: OnceCell<AppHandle> = OnceCell::new();
pub fn app_handle() -> &'static AppHandle {
APP_HANDLE.get().expect("app handle not initialized")
}
pub(super) fn setup_app_handle(app_handle: AppHandle) {
let _ = APP_HANDLE.set(app_handle);
}

View File

@@ -1,8 +1,13 @@
use backon::ExponentialBuilder;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use specta::Type;
use tauri_specta::Event;
pub mod api;
pub mod core;
pub mod proxies;
pub mod ws;
pub static CLASH_API_DEFAULT_BACKOFF_STRATEGY: Lazy<ExponentialBuilder> = Lazy::new(|| {
ExponentialBuilder::default()
@@ -10,3 +15,25 @@ pub static CLASH_API_DEFAULT_BACKOFF_STRATEGY: Lazy<ExponentialBuilder> = Lazy::
.with_max_delay(std::time::Duration::from_secs(5))
.with_max_times(5)
});
#[derive(Serialize, Deserialize, Debug, Clone, Type, Event)]
pub struct ClashConnectionsEvent(pub ws::ClashConnectionsConnectorEvent);
pub fn setup<R: tauri::Runtime, M: tauri::Manager<R>>(manager: &M) -> anyhow::Result<()> {
let ws_connector = ws::ClashConnectionsConnector::new();
manager.manage(ws_connector.clone());
let app_handle = manager.app_handle().clone();
tauri::async_runtime::spawn(async move {
// TODO: refactor it while clash core manager use tauri event dispatcher to notify the core state changed
{
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
ws_connector.start().await.unwrap();
}
let mut rx = ws_connector.subscribe();
while let Ok(event) = rx.recv().await {
ClashConnectionsEvent(event).emit(&app_handle).unwrap();
}
});
Ok(())
}

View File

@@ -261,7 +261,7 @@ type ProxiesGuardSingleton = &'static Arc<RwLock<ProxiesGuard>>;
impl ProxiesGuardExt for ProxiesGuardSingleton {
async fn update(&self) -> Result<()> {
let proxies = Proxies::fetch().await?;
let buf = simd_json::to_string(&proxies)?;
let buf = serde_json::to_string(&proxies)?;
let checksum = adler32(buf.as_bytes())?;
{
let reader = self.read();

View File

@@ -0,0 +1,286 @@
use std::{
future::Future,
ops::Deref,
sync::{atomic::Ordering, Arc},
};
use anyhow::Context;
use atomic_enum::atomic_enum;
use backon::Retryable;
use futures_util::StreamExt;
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use specta::Type;
use tokio::{sync::mpsc::Receiver, task::JoinHandle};
use tokio_tungstenite::{
connect_async,
tungstenite::{client::IntoClientRequest, handshake::client::Request, protocol::Message},
};
use crate::log_err;
#[tracing::instrument]
async fn connect_clash_server<T: serde::de::DeserializeOwned + Send + Sync + 'static>(
endpoint: Request,
) -> anyhow::Result<Receiver<T>> {
let (stream, _) = connect_async(endpoint).await?;
let (_, mut read) = stream.split();
let (tx, rx) = tokio::sync::mpsc::channel(32);
tokio::spawn(async move {
while let Some(msg) = read.next().await {
match msg {
Ok(Message::Text(text)) => match serde_json::from_str(&text) {
Ok(data) => {
let _ = tx.send(data).await;
}
Err(e) => {
tracing::error!("failed to deserialize json: {}", e);
}
},
Ok(Message::Binary(bin)) => match serde_json::from_slice(&bin) {
Ok(data) => {
let _ = tx.send(data).await;
}
Err(e) => {
tracing::error!("failed to deserialize json: {}", e);
}
},
Ok(Message::Close(_)) => {
tracing::info!("server closed connection");
break;
}
Err(e) => {
tracing::error!("failed to read message: {}", e);
}
_ => {}
}
}
});
Ok(rx)
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClashConnectionsMessage {
download_total: u64,
upload_total: u64,
// other fields are omitted
}
#[derive(Debug, Clone, Default, Copy, Type, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ClashConnectionsInfo {
pub download_total: u64,
pub upload_total: u64,
pub download_speed: u64,
pub upload_speed: u64,
}
#[derive(Debug, Clone, Type, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[serde(tag = "kind", content = "data")]
pub enum ClashConnectionsConnectorEvent {
StateChanged(ClashConnectionsConnectorState),
Update(ClashConnectionsInfo),
}
#[derive(PartialEq, Eq, Type, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[atomic_enum]
pub enum ClashConnectionsConnectorState {
Disconnected,
Connecting,
Connected,
}
pub struct ClashConnectionsConnectorInner {
state: AtomicClashConnectionsConnectorState,
connection_handler: Mutex<Option<JoinHandle<()>>>,
broadcast_tx: tokio::sync::broadcast::Sender<ClashConnectionsConnectorEvent>,
info: Mutex<ClashConnectionsInfo>,
}
// TODO:
#[derive(Clone)]
pub struct ClashConnectionsConnector {
inner: Arc<ClashConnectionsConnectorInner>,
}
impl Deref for ClashConnectionsConnector {
type Target = ClashConnectionsConnectorInner;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl ClashConnectionsConnector {
pub fn new() -> Self {
Self {
inner: Arc::new(ClashConnectionsConnectorInner::new()),
}
}
pub fn endpoint() -> anyhow::Result<Request> {
let (server, secret) = {
let info = crate::Config::clash().data().get_client_info();
(info.server, info.secret)
};
let url = format!("ws://{}/connections", server);
let mut request = url
.into_client_request()
.context("failed to create client request")?;
if let Some(secret) = secret {
request.headers_mut().insert(
"Authorization",
format!("Bearer {}", secret)
.parse()
.context("failed to create header value")?,
);
}
Ok(request)
}
#[allow(clippy::manual_async_fn)]
// FIXME: move to async fn while rust new solver got merged
// ref: https://github.com/rust-lang/rust/issues/123072
fn start_internal(&self) -> impl Future<Output = anyhow::Result<()>> + Send + use<'_> {
async {
self.dispatch_state_changed(ClashConnectionsConnectorState::Connecting);
let endpoint = Self::endpoint().context("failed to create endpoint")?;
log::debug!("connecting to clash connections ws server: {:?}", endpoint);
let mut rx = connect_clash_server::<ClashConnectionsMessage>(endpoint).await?;
self.dispatch_state_changed(ClashConnectionsConnectorState::Connected);
let this = self.clone();
let mut connection_handler = self.connection_handler.lock();
let handle = tokio::spawn(async move {
loop {
match rx.recv().await {
Some(msg) => {
this.update(msg);
}
None => {
tracing::info!("clash ws server closed connection, trying to restart");
// The connection was closed, let's restart the connector
this.dispatch_state_changed(
ClashConnectionsConnectorState::Disconnected,
);
tokio::spawn(async move {
let restart = async || this.restart().await;
log_err!(restart
.retry(backon::ExponentialBuilder::default())
.sleep(tokio::time::sleep)
.await
.context("failed to restart clash connections"));
});
break;
}
}
}
});
*connection_handler = Some(handle);
Ok(())
}
}
pub async fn start(&self) -> anyhow::Result<()> {
self.start_internal().await.inspect_err(|_| {
self.dispatch_state_changed(ClashConnectionsConnectorState::Disconnected);
})
}
pub async fn restart(&self) -> anyhow::Result<()> {
self.stop().await;
self.start().await
}
}
impl ClashConnectionsConnectorInner {
pub fn new() -> Self {
Self {
state: AtomicClashConnectionsConnectorState::new(
ClashConnectionsConnectorState::Disconnected,
),
connection_handler: Mutex::new(None),
broadcast_tx: tokio::sync::broadcast::channel(5).0,
info: Mutex::new(ClashConnectionsInfo::default()),
}
}
pub fn state(&self) -> ClashConnectionsConnectorState {
self.state.load(Ordering::Acquire)
}
fn dispatch_state_changed(&self, state: ClashConnectionsConnectorState) {
self.state.store(state, Ordering::Release);
// SAFETY: the failures only there no active receivers,
// so that the message will be dropped directly
let _ = self
.broadcast_tx
.send(ClashConnectionsConnectorEvent::StateChanged(state));
}
/// Subscribe to the ClashConnectionsConnectorEvent
pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ClashConnectionsConnectorEvent> {
self.broadcast_tx.subscribe()
}
fn update(&self, msg: ClashConnectionsMessage) {
let mut info = self.info.lock();
let previous_download_total =
std::mem::replace(&mut info.download_total, msg.download_total);
let previous_upload_total = std::mem::replace(&mut info.upload_total, msg.upload_total);
info.download_speed = msg
.download_total
.checked_sub(previous_download_total)
.unwrap_or_default();
info.upload_speed = msg
.upload_total
.checked_sub(previous_upload_total)
.unwrap_or_default();
// SAFETY: the failures only there no active receivers,
// so that the message will be dropped directly
let _ = self
.broadcast_tx
.send(ClashConnectionsConnectorEvent::Update(*info));
}
pub async fn stop(&self) {
log::info!("stopping clash connections ws server");
let handle = self.connection_handler.lock().take();
if let Some(handle) = handle {
handle.abort();
let _ = handle.await;
}
self.dispatch_state_changed(ClashConnectionsConnectorState::Disconnected);
}
}
impl Drop for ClashConnectionsConnectorInner {
fn drop(&mut self) {
let cleanup = async move {
self.stop().await;
};
match tokio::runtime::Handle::try_current() {
Ok(_) => tokio::task::block_in_place(|| {
tauri::async_runtime::block_on(cleanup);
}),
Err(_) => {
tauri::async_runtime::block_on(cleanup);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_connect_clash_server() {
"ws://127.0.0.1:12649:10808/connections"
.into_client_request()
.unwrap();
}
}

View File

@@ -347,5 +347,5 @@ pub async fn status<'a>() -> anyhow::Result<nyanpasu_ipc::types::StatusInfo<'a>>
}
let mut status = String::from_utf8(output.stdout)?;
tracing::trace!("service status: {}", status);
Ok(unsafe { simd_json::serde::from_str(&mut status)? })
Ok(serde_json::from_str(&mut status)?)
}

View File

@@ -101,7 +101,7 @@ where
/// get the current state, it will return the ManagedStateLocker for the state
pub fn latest(&self) -> MappedRwLockReadGuard<'_, T> {
if self.is_dirty.load(std::sync::atomic::Ordering::Relaxed) {
if self.is_dirty() {
let draft = self.draft.read();
RwLockReadGuard::map(draft, |guard| guard.as_ref().unwrap())
} else {
@@ -125,8 +125,8 @@ where
self.is_dirty
.store(true, std::sync::atomic::Ordering::Release);
RwLockWriteGuard::map(self.draft.write(), |guard| {
*guard = Some(state.clone());
RwLockWriteGuard::map(self.draft.write(), move |guard| {
*guard = Some(state);
guard.as_mut().unwrap()
})
}

View File

@@ -73,8 +73,7 @@ impl JobExt for EventsRotateJob {
fn setup(&self) -> Option<crate::core::tasks::task::Task> {
Some(crate::core::tasks::task::Task {
name: CLEAR_EVENTS_TASK_NAME.to_string(),
// 12:00 every day
schedule: TaskSchedule::Cron("0 12 * * *".to_string()),
schedule: TaskSchedule::Cron("@hourly".to_string()),
executor: TaskExecutor::Async(Box::new(self.clone())),
..Default::default()
})

View File

@@ -33,8 +33,7 @@ impl TaskStorage {
let value = table.get(Self::TASKS_KEY.as_bytes())?;
match value {
Some(value) => {
let mut value = value.value().to_owned();
let tasks: Vec<TaskID> = simd_json::from_slice(value.as_mut_slice())?;
let tasks: Vec<TaskID> = serde_json::from_slice(value.value())?;
Ok(tasks)
}
None => Ok(Vec::new()),
@@ -50,14 +49,12 @@ impl TaskStorage {
let mut tasks = table
.get(Self::TASKS_KEY.as_bytes())?
.and_then(|val| {
let mut value = val.value().to_owned();
let tasks: HashSet<TaskID> =
simd_json::from_slice(value.as_mut_slice()).ok()?;
let tasks: HashSet<TaskID> = serde_json::from_slice(val.value()).ok()?;
Some(tasks)
})
.unwrap_or_default();
tasks.insert(task_id);
let value = simd_json::to_vec(&tasks)?;
let value = serde_json::to_vec(&tasks)?;
table.insert(Self::TASKS_KEY.as_bytes(), value.as_slice())?;
}
write_txn.commit()?;
@@ -85,8 +82,7 @@ impl TaskStorage {
let value = table.get(key.as_bytes())?;
match value {
Some(value) => {
let mut value = value.value().to_owned();
let event: TaskEvent = simd_json::from_slice(value.as_mut_slice())?;
let event: TaskEvent = serde_json::from_slice(value.value())?;
Ok(Some(event))
}
None => Ok(None),
@@ -116,10 +112,7 @@ impl TaskStorage {
let table = read_txn.open_table(NYANPASU_TABLE)?;
let value = table.get(key.as_bytes())?;
let value: Vec<TaskEventID> = match value {
Some(value) => {
let mut value = value.value().to_owned();
simd_json::from_slice(value.as_mut_slice())?
}
Some(value) => serde_json::from_slice(value.value())?,
None => return Ok(None),
};
Ok(Some(value))
@@ -133,8 +126,8 @@ impl TaskStorage {
let db = self.storage.get_instance();
let event_key = format!("task:event:id:{}", event.id);
let event_ids_key = format!("task:events:task_id:{}", event.task_id);
let event_value = simd_json::to_vec(event)?;
let event_ids = simd_json::to_vec(&event_ids)?;
let event_value = serde_json::to_vec(event)?;
let event_ids = serde_json::to_vec(&event_ids)?;
let write_txn = db.begin_write()?;
{
let mut table = write_txn.open_table(NYANPASU_TABLE)?;
@@ -149,7 +142,7 @@ impl TaskStorage {
pub fn update_event(&self, event: &TaskEvent) -> Result<()> {
let db = self.storage.get_instance();
let event_key = format!("task:event:id:{}", event.id);
let event_value = simd_json::to_vec(event)?;
let event_value = serde_json::to_vec(event)?;
let write_txn = db.begin_write()?;
{
let mut table = write_txn.open_table(NYANPASU_TABLE)?;
@@ -176,7 +169,7 @@ impl TaskStorage {
if event_ids.is_empty() {
table.remove(event_ids_key.as_bytes())?;
} else {
let event_ids = simd_json::to_vec(&event_ids)?;
let event_ids = serde_json::to_vec(&event_ids)?;
table.insert(event_ids_key.as_bytes(), event_ids.as_slice())?;
}
}
@@ -211,7 +204,7 @@ impl TaskGuard for TaskManager {
let key = key.value();
let mut value = value.value().to_owned();
if key.starts_with(b"task:id:") {
let task = simd_json::from_slice::<Task>(value.as_mut_slice())?;
let task = serde_json::from_slice::<Task>(value.as_mut_slice())?;
debug!(
"restore task: {:?} {:?}",
str::from_utf8(key).unwrap(),
@@ -234,7 +227,7 @@ impl TaskGuard for TaskManager {
let mut table = write_txn.open_table(NYANPASU_TABLE)?;
for task in tasks {
let key = format!("task:id:{}", task.id);
let value = simd_json::to_vec(&task)?;
let value = serde_json::to_vec(&task)?;
table.insert(key.as_bytes(), value.as_slice())?;
}
}

View File

@@ -31,7 +31,7 @@ pub enum Error {
DatabaseCommitOperationFailed(#[from] redb::CommitError),
#[error("json parse failed: {0:?}")]
JsonParseFailed(#[from] simd_json::Error),
JsonParseFailed(#[from] serde_json::Error),
#[error("task issue failed: {message:?}")]
InnerTask {

View File

@@ -182,11 +182,11 @@ impl Runner for JSRunner {
let boa_runner = wrap_result!(BoaRunner::try_new(), take_logs(logs));
wrap_result!(boa_runner.setup_console(logger), take_logs(logs));
let config = wrap_result!(
simd_json::serde::to_string(&mapping)
serde_json::to_string(&mapping)
.map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, e) }),
take_logs(logs)
);
let config = simd_json::to_string(&config).unwrap(); // escape the string
let config = serde_json::to_string(&config).unwrap(); // escape the string
let execute_module = format!(
r#"import process from "./{hash}.mjs";
let config = JSON.parse({config});
@@ -220,7 +220,7 @@ impl Runner for JSRunner {
take_logs(logs)
);
let mapping = wrap_result!(
unsafe { simd_json::serde::from_str::<Mapping>(&mut result) }
serde_json::from_str(&result)
.map_err(|e| { std::io::Error::new(std::io::ErrorKind::InvalidData, e) }),
take_logs(logs)
);
@@ -451,7 +451,7 @@ const foreignNameservers = [
"Test".to_string()
),])
);
let outs = simd_json::serde::to_string(&logs).unwrap();
let outs = serde_json::to_string(&logs).unwrap();
assert_eq!(
outs,
r#"[["log","Test console log"],["warn","Test console log"],["error","Test console log"]]"#
@@ -516,7 +516,7 @@ const foreignNameservers = [
serde_yaml::Value::String("RULE-SET,custom-proxy,🚀".to_string())
])
);
let outs = simd_json::serde::to_string(&logs).unwrap();
let outs = serde_json::to_string(&logs).unwrap();
assert_eq!(outs, r#"[]"#);
});
}

View File

@@ -0,0 +1,12 @@
/// This module is a tauri event based handler.
/// Some state is good to be managed by the Tauri Manager. we should not hold the singletons in the global state in some cases.
use tauri::{Emitter, Listener, Manager, Runtime};
mod widget;
pub fn mount_handlers<M, R>(app: &mut M)
where
M: Manager<R> + Listener<R> + Emitter<R>,
R: Runtime,
{
}

View File

@@ -0,0 +1,35 @@
use crate::config::nyanpasu::NetworkStatisticWidgetConfig;
use anyhow::Context;
use tauri::{AppHandle, Event, Runtime};
pub enum WidgetInstance {
Small(nyanpasu_egui::widget::NyanpasuNetworkStatisticSmallWidget),
Large(nyanpasu_egui::widget::NyanpasuNetworkStatisticLargeWidget),
}
#[tracing::instrument(skip(app_handle))]
pub(super) fn on_network_statistic_config_changed<R: Runtime>(
app_handle: &AppHandle<R>,
event: Event,
) -> anyhow::Result<()> {
// let config: NetworkStatisticWidgetConfig =
// serde_json::from_str(event.payload()).context("failed to deserialize the new config")?;
// match config {
// NetworkStatisticWidgetConfig::Disabled => {
// app_handle
// .emit_all("network-statistic-widget:hide")
// .context("failed to emit the hide event")?;
// }
// NetworkStatisticWidgetConfig::Large => {
// app_handle
// .emit_all("network-statistic-widget:show-large")
// .context("failed to emit the show-large event")?;
// }
// NetworkStatisticWidgetConfig::Small => {
// app_handle
// .emit_all("network-statistic-widget:show-small")
// .context("failed to emit the show-small event")?;
// }
// }
Ok(())
}

View File

@@ -7,16 +7,17 @@
use std::borrow::Borrow;
use crate::{
config::*,
config::{nyanpasu::NetworkStatisticWidgetConfig, *},
core::{service::ipc::get_ipc_state, *},
log_err,
utils::{self, help::get_clash_external_port, resolve},
};
use anyhow::{bail, Result};
use handle::Message;
use nyanpasu_egui::widget::network_statistic_large;
use nyanpasu_ipc::api::status::CoreState;
use serde_yaml::{Mapping, Value};
use tauri::AppHandle;
use tauri::{AppHandle, Manager};
use tauri_plugin_clipboard_manager::ClipboardExt;
// 打开面板
@@ -285,7 +286,7 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
let log_level = patch.app_log_level;
let log_max_files = patch.max_log_files;
let enable_tray_selector = patch.clash_tray_selector;
let network_statistic_widget = patch.network_statistic_widget;
let res = || async move {
let service_mode = patch.enable_service_mode;
let ipc_state = get_ipc_state();
@@ -362,6 +363,24 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> {
handle::Handle::update_systray()?;
}
// TODO: refactor config with changed notify
if network_statistic_widget.is_some() {
let network_statistic_widget = network_statistic_widget.unwrap();
let widget_manager =
crate::consts::app_handle().state::<crate::widget::WidgetManager>();
let is_running = widget_manager.is_running().await;
match network_statistic_widget {
NetworkStatisticWidgetConfig::Disabled => {
if is_running {
widget_manager.stop().await?;
}
}
NetworkStatisticWidgetConfig::Enabled(variant) => {
widget_manager.start(variant).await?;
}
}
}
<Result<()>>::Ok(())
};

View File

@@ -1,5 +1,5 @@
use crate::{
config::*,
config::{profile::ProfileBuilder, *},
core::{storage::Storage, tasks::jobs::ProfilesJobGuard, updater::ManifestVersionLatest, *},
enhance::PostProcessingOutput,
feat,
@@ -147,26 +147,26 @@ pub async fn import_profile(url: String, option: Option<RemoteProfileOptionsBuil
/// create a new profile
#[tauri::command]
#[specta::specta]
pub async fn create_profile(item: ProfileKind, file_data: Option<String>) -> Result {
pub async fn create_profile(item: ProfileBuilder, file_data: Option<String>) -> Result {
tracing::trace!("create profile: {item:?}");
let is_remote = matches!(&item, ProfileKind::Remote(_));
let is_remote = matches!(&item, ProfileBuilder::Remote(_));
let profile: Profile = match item {
ProfileKind::Local(builder) => builder
ProfileBuilder::Local(builder) => builder
.build()
.context("failed to build local profile")?
.into(),
ProfileKind::Remote(mut builder) => builder
ProfileBuilder::Remote(mut builder) => builder
.build_no_blocking()
.await
.context("failed to build remote profile")?
.into(),
ProfileKind::Merge(builder) => builder
ProfileBuilder::Merge(builder) => builder
.build()
.context("failed to build merge profile")?
.into(),
ProfileKind::Script(builder) => builder
ProfileBuilder::Script(builder) => builder
.build()
.context("failed to build script profile")?
.into(),
@@ -277,7 +277,7 @@ pub async fn patch_profiles_config(profiles: ProfilesBuilder) -> Result {
/// update profile by uid
#[tauri::command]
#[specta::specta]
pub async fn patch_profile(app_handle: AppHandle, uid: String, profile: ProfileKind) -> Result {
pub async fn patch_profile(app_handle: AppHandle, uid: String, profile: ProfileBuilder) -> Result {
tracing::debug!("patch profile: {uid} with {profile:?}");
{
let committer = Config::profiles().auto_commit();
@@ -419,9 +419,6 @@ pub fn get_postprocessing_output() -> Result<PostProcessingOutput> {
Ok(Config::runtime().latest().postprocessing_output.clone())
}
#[derive(specta::Type)]
pub struct Test<'n>(Cow<'n, CoreState>, i64, RunType);
#[tauri::command]
#[specta::specta]
pub async fn get_core_status<'n>() -> Result<(Cow<'n, CoreState>, i64, RunType)> {
@@ -971,3 +968,12 @@ pub fn remove_storage_item(app_handle: AppHandle, key: String) -> Result {
(storage.remove_item(&key))?;
Ok(())
}
#[tauri::command]
#[specta::specta]
pub async fn get_clash_ws_connections_state(
app_handle: AppHandle,
) -> Result<crate::core::clash::ws::ClashConnectionsConnectorState> {
let ws_connector = app_handle.state::<crate::core::clash::ws::ClashConnectionsConnector>();
Ok(ws_connector.state())
}

View File

@@ -10,11 +10,13 @@ mod config;
mod consts;
mod core;
mod enhance;
mod event_handler;
mod feat;
mod ipc;
mod server;
mod setup;
mod utils;
mod widget;
mod window;
use std::io;
@@ -26,7 +28,7 @@ use crate::{
};
use specta_typescript::{BigIntExportBehavior, Typescript};
use tauri::{Emitter, Manager};
use tauri_specta::collect_commands;
use tauri_specta::{collect_commands, collect_events};
use utils::resolve::{is_window_opened, reset_window_open_counter};
rust_i18n::i18n!("../../locales");
@@ -176,84 +178,88 @@ pub fn run() -> std::io::Result<()> {
}));
// setup specta
let specta_builder = tauri_specta::Builder::<tauri::Wry>::new().commands(collect_commands![
// common
ipc::get_sys_proxy,
ipc::open_app_config_dir,
ipc::open_app_data_dir,
ipc::open_logs_dir,
ipc::open_web_url,
ipc::open_core_dir,
// cmds::kill_sidecar,
ipc::restart_sidecar,
// clash
ipc::get_clash_info,
ipc::get_clash_logs,
ipc::patch_clash_config,
ipc::change_clash_core,
ipc::get_runtime_config,
ipc::get_runtime_yaml,
ipc::get_runtime_exists,
ipc::get_postprocessing_output,
ipc::clash_api_get_proxy_delay,
ipc::uwp::invoke_uwp_tool,
// updater
ipc::fetch_latest_core_versions,
ipc::update_core,
ipc::inspect_updater,
ipc::get_core_version,
// utils
ipc::collect_logs,
// verge
ipc::get_verge_config,
ipc::patch_verge_config,
// cmds::update_hotkeys,
// profile
ipc::get_profiles,
ipc::enhance_profiles,
ipc::patch_profiles_config,
ipc::view_profile,
ipc::patch_profile,
ipc::create_profile,
ipc::import_profile,
ipc::reorder_profile,
ipc::reorder_profiles_by_list,
ipc::update_profile,
ipc::delete_profile,
ipc::read_profile_file,
ipc::save_profile_file,
ipc::save_window_size_state,
ipc::get_custom_app_dir,
ipc::set_custom_app_dir,
// service mode
ipc::service::status_service,
ipc::service::install_service,
ipc::service::uninstall_service,
ipc::service::start_service,
ipc::service::stop_service,
ipc::service::restart_service,
ipc::is_portable,
ipc::get_proxies,
ipc::select_proxy,
ipc::update_proxy_provider,
ipc::restart_application,
ipc::collect_envs,
ipc::get_server_port,
ipc::set_tray_icon,
ipc::is_tray_icon_set,
ipc::get_core_status,
ipc::url_delay_test,
ipc::get_ipsb_asn,
ipc::open_that,
ipc::is_appimage,
ipc::get_service_install_prompt,
ipc::cleanup_processes,
ipc::get_storage_item,
ipc::set_storage_item,
ipc::remove_storage_item,
ipc::mutate_proxies,
ipc::get_core_dir,
]);
let specta_builder = tauri_specta::Builder::<tauri::Wry>::new()
.commands(collect_commands![
// common
ipc::get_sys_proxy,
ipc::open_app_config_dir,
ipc::open_app_data_dir,
ipc::open_logs_dir,
ipc::open_web_url,
ipc::open_core_dir,
// cmds::kill_sidecar,
ipc::restart_sidecar,
// clash
ipc::get_clash_info,
ipc::get_clash_logs,
ipc::patch_clash_config,
ipc::change_clash_core,
ipc::get_runtime_config,
ipc::get_runtime_yaml,
ipc::get_runtime_exists,
ipc::get_postprocessing_output,
ipc::clash_api_get_proxy_delay,
ipc::uwp::invoke_uwp_tool,
// updater
ipc::fetch_latest_core_versions,
ipc::update_core,
ipc::inspect_updater,
ipc::get_core_version,
// utils
ipc::collect_logs,
// verge
ipc::get_verge_config,
ipc::patch_verge_config,
// cmds::update_hotkeys,
// profile
ipc::get_profiles,
ipc::enhance_profiles,
ipc::patch_profiles_config,
ipc::view_profile,
ipc::patch_profile,
ipc::create_profile,
ipc::import_profile,
ipc::reorder_profile,
ipc::reorder_profiles_by_list,
ipc::update_profile,
ipc::delete_profile,
ipc::read_profile_file,
ipc::save_profile_file,
ipc::save_window_size_state,
ipc::get_custom_app_dir,
ipc::set_custom_app_dir,
// service mode
ipc::service::status_service,
ipc::service::install_service,
ipc::service::uninstall_service,
ipc::service::start_service,
ipc::service::stop_service,
ipc::service::restart_service,
ipc::is_portable,
ipc::get_proxies,
ipc::select_proxy,
ipc::update_proxy_provider,
ipc::restart_application,
ipc::collect_envs,
ipc::get_server_port,
ipc::set_tray_icon,
ipc::is_tray_icon_set,
ipc::get_core_status,
ipc::url_delay_test,
ipc::get_ipsb_asn,
ipc::open_that,
ipc::is_appimage,
ipc::get_service_install_prompt,
ipc::cleanup_processes,
ipc::get_storage_item,
ipc::set_storage_item,
ipc::remove_storage_item,
ipc::mutate_proxies,
ipc::get_core_dir,
// clash layer
ipc::get_clash_ws_connections_state,
])
.events(collect_events![core::clash::ClashConnectionsEvent]);
#[cfg(debug_assertions)]
{
@@ -310,7 +316,9 @@ pub fn run() -> std::io::Result<()> {
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_global_shortcut::Builder::default().build())
.setup(|app| {
.setup(move |app| {
specta_builder.mount_events(app);
#[cfg(target_os = "macos")]
{
use tauri::menu::{MenuBuilder, SubmenuBuilder};

View File

@@ -108,6 +108,7 @@ pub fn resolve_setup(app: &mut App) {
});
handle::Handle::global().init(app.app_handle().clone());
crate::consts::setup_app_handle(app.app_handle().clone());
log_err!(init::init_resources());
log_err!(init::init_service());
@@ -150,6 +151,19 @@ pub fn resolve_setup(app: &mut App) {
log::trace!("launch core");
log_err!(CoreManager::global().init());
log::trace!("init clash connection connector");
log_err!(crate::core::clash::setup(app));
log::trace!("init widget manager");
log_err!(tauri::async_runtime::block_on(async {
crate::widget::setup(app, {
let manager = app.state::<crate::core::clash::ws::ClashConnectionsConnector>();
manager.subscribe()
})
.await
}));
#[cfg(any(windows, target_os = "linux"))]
log::trace!("init system tray");
#[cfg(any(windows, target_os = "linux"))]
tray::icon::resize_images(crate::utils::help::get_max_scale_factor()); // generate latest cache icon by current scale factor

View File

@@ -0,0 +1,211 @@
use crate::config::{nyanpasu::NetworkStatisticWidgetConfig, Config};
use super::core::clash::ws::ClashConnectionsConnectorEvent;
use anyhow::Context;
use nyanpasu_egui::{
ipc::{create_ipc_server, IpcSender, Message, StatisticMessage},
widget::StatisticWidgetVariant,
};
use std::sync::{atomic::AtomicBool, Arc};
use tauri::{utils::platform::current_exe, Manager, Runtime};
use tokio::{
process::Child,
sync::{
broadcast::{error::RecvError as BroadcastRecvError, Receiver as BroadcastReceiver},
Mutex,
},
};
#[derive(Clone)]
pub struct WidgetManager {
instance: Arc<Mutex<Option<WidgetManagerInstance>>>,
listener_initd: Arc<AtomicBool>,
}
struct WidgetManagerInstance {
tx: IpcSender<Message>,
process: Child,
}
impl WidgetManager {
pub fn new() -> Self {
Self {
instance: Arc::new(Mutex::new(None)),
listener_initd: Arc::new(AtomicBool::new(false)),
}
}
fn register_listener(&self, mut receiver: BroadcastReceiver<ClashConnectionsConnectorEvent>) {
if self
.listener_initd
.load(std::sync::atomic::Ordering::Acquire)
{
return;
}
let signal = self.listener_initd.clone();
let this = self.clone();
tokio::spawn(async move {
loop {
match receiver.recv().await {
Ok(event) => {
if let Err(e) = this.handle_event(event).await {
log::error!("Failed to handle event: {}", e);
}
}
Err(e) => {
log::error!("Error receiving event: {}", e);
if BroadcastRecvError::Closed == e {
signal.store(false, std::sync::atomic::Ordering::Release);
break;
}
}
}
}
});
self.listener_initd
.store(true, std::sync::atomic::Ordering::Release);
}
async fn handle_event(&self, event: ClashConnectionsConnectorEvent) -> anyhow::Result<()> {
let mut instance = self.instance.clone().lock_owned().await;
if let ClashConnectionsConnectorEvent::Update(info) = event {
if instance
.as_mut()
.is_some_and(|instance| instance.is_alive())
{
tokio::task::spawn_blocking(move || {
let instance = instance.as_ref().unwrap();
// we only care about the update event now
instance
.tx
.send(Message::UpdateStatistic(StatisticMessage {
download_total: info.download_total,
upload_total: info.upload_total,
download_speed: info.download_speed,
upload_speed: info.upload_speed,
}))
.context("Failed to send event to widget")?;
Ok::<(), anyhow::Error>(())
})
.await
.context("Failed to send event to widget")??;
}
}
Ok(())
}
pub async fn start(&self, widget: StatisticWidgetVariant) -> anyhow::Result<()> {
let mut instance = self.instance.lock().await;
if instance.is_some() {
log::info!("Widget already running, stopping it first...");
self.stop().await.context("Failed to stop widget")?;
}
let current_exe = current_exe().context("Failed to get current executable")?;
// This operation is blocking, but it internal just a system call, so I think it's okay
let (mut ipc_server, server_name) = create_ipc_server()?;
// spawn a process to run the widget
let child = tokio::process::Command::new(current_exe)
.arg("statistic-widget")
.arg(serde_json::to_string(&widget).context("Failed to serialize widget")?)
.env("NYANPASU_EGUI_IPC_SERVER", server_name)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.context("Failed to spawn widget process")?;
let tx = tokio::task::spawn_blocking(move || {
ipc_server
.connect()
.context("Failed to connect to widget")?;
ipc_server.into_tx().context("Failed to get ipc sender")
})
.await
.context("Failed to read widget output")??;
instance.replace(WidgetManagerInstance { tx, process: child });
Ok(())
}
pub async fn stop(&self) -> anyhow::Result<()> {
let Some(mut instance) = self.instance.lock().await.take() else {
return Ok(());
};
if !instance.is_alive() {
return Ok(());
}
// first try to stop the process gracefully
let mut instance = tokio::task::spawn_blocking(move || {
instance
.tx
.send(Message::Stop)
.context("Failed to send stop message to widget")?;
Ok::<WidgetManagerInstance, anyhow::Error>(instance)
})
.await
.context("Failed to kill widget process")??;
for _ in 0..5 {
if instance.is_alive() {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
} else {
return Ok(());
}
}
// force kill the process
instance
.process
.kill()
.await
.context("Failed to kill widget process")?;
Ok(())
}
pub async fn is_running(&self) -> bool {
let mut instance = self.instance.lock().await;
instance
.as_mut()
.is_some_and(|instance| instance.is_alive())
}
}
impl WidgetManagerInstance {
pub fn is_alive(&mut self) -> bool {
self.process.try_wait().is_ok_and(|status| status.is_none())
}
}
impl Drop for WidgetManager {
fn drop(&mut self) {
let cleanup = async {
let _ = self.stop().await;
};
match tokio::runtime::Handle::try_current() {
Ok(_) => {
tokio::task::block_in_place(move || {
tauri::async_runtime::block_on(cleanup);
});
}
Err(_) => {
tauri::async_runtime::block_on(cleanup);
}
}
}
}
pub async fn setup<R: Runtime, M: Manager<R>>(
manager: &M,
ws_connections_receiver: BroadcastReceiver<ClashConnectionsConnectorEvent>,
) -> anyhow::Result<()> {
let widget_manager = WidgetManager::new();
// TODO: use the app_handle to read initial config.
let option = Config::verge()
.data()
.network_statistic_widget
.unwrap_or_default();
widget_manager.register_listener(ws_connections_receiver);
if let NetworkStatisticWidgetConfig::Enabled(widget) = option {
widget_manager.start(widget).await?;
}
// TODO: subscribe to the config change event
manager.manage(widget_manager);
Ok(())
}

View File

@@ -11,6 +11,7 @@
"build": "tsc"
},
"dependencies": {
"@tanstack/react-query": "5.66.0",
"@tauri-apps/api": "2.2.0",
"ahooks": "3.8.4",
"ofetch": "1.4.1",

View File

@@ -1,3 +1,5 @@
export * from './ipc'
export * from './service'
export * from './openapi'
export * from './provider'
export * from './service'
export * from './utils'

View File

@@ -304,7 +304,7 @@ export const commands = {
*/
async patchProfile(
uid: string,
profile: ProfileKind,
profile: ProfileBuilder,
): Promise<Result<null, string>> {
try {
return {
@@ -320,7 +320,7 @@ export const commands = {
* create a new profile
*/
async createProfile(
item: ProfileKind,
item: ProfileBuilder,
fileData: string | null,
): Promise<Result<null, string>> {
try {
@@ -707,10 +707,29 @@ export const commands = {
else return { status: 'error', error: e as any }
}
},
async getClashWsConnectionsState(): Promise<
Result<ClashConnectionsConnectorState, string>
> {
try {
return {
status: 'ok',
data: await TAURI_INVOKE('get_clash_ws_connections_state'),
}
} catch (e) {
if (e instanceof Error) throw e
else return { status: 'error', error: e as any }
}
},
}
/** user-defined events **/
export const events = __makeEvents__<{
clashConnectionsEvent: ClashConnectionsEvent
}>({
clashConnectionsEvent: 'clash-connections-event',
})
/** user-defined constants **/
/** user-defined types **/
@@ -736,6 +755,20 @@ export type ChunkStatus = {
speed: number
}
export type ChunkThreadState = 'Idle' | 'Downloading' | 'Finished'
export type ClashConnectionsConnectorEvent =
| { kind: 'state_changed'; data: ClashConnectionsConnectorState }
| { kind: 'update'; data: ClashConnectionsInfo }
export type ClashConnectionsConnectorState =
| 'disconnected'
| 'connecting'
| 'connected'
export type ClashConnectionsEvent = ClashConnectionsConnectorEvent
export type ClashConnectionsInfo = {
downloadTotal: number
uploadTotal: number
downloadSpeed: number
uploadSpeed: number
}
export type ClashCore =
| 'clash'
| 'clash-rs'
@@ -964,6 +997,10 @@ export type IVerge = {
* TODO: 弃用此字段,转移到 clash config 里
*/
tun_stack: TunStack | null
/**
* 是否启用网络统计信息浮窗
*/
network_statistic_widget?: NetworkStatisticWidgetConfig | null
}
export type IVergeTheme = {
primary_color: string | null
@@ -1122,6 +1159,9 @@ export type MergeProfileBuilder = {
*/
updated: number | null
}
export type NetworkStatisticWidgetConfig =
| { kind: 'disabled' }
| { kind: 'enabled'; value: StatisticWidgetVariant }
export type PatchRuntimeConfig = {
allow_lan?: boolean | null
ipv6?: boolean | null
@@ -1148,20 +1188,20 @@ export type PostProcessingOutput = {
advice: [LogSpan, string][]
}
export type Profile =
| { Remote: RemoteProfile }
| { Local: LocalProfile }
| { Merge: MergeProfile }
| { Script: ScriptProfile }
| RemoteProfile
| LocalProfile
| MergeProfile
| ScriptProfile
export type ProfileBuilder =
| RemoteProfileBuilder
| LocalProfileBuilder
| MergeProfileBuilder
| ScriptProfileBuilder
export type ProfileItemType =
| 'remote'
| 'local'
| { script: ScriptType }
| 'merge'
export type ProfileKind =
| { Remote: RemoteProfileBuilder }
| { Local: LocalProfileBuilder }
| { Merge: MergeProfileBuilder }
| { Script: ScriptProfileBuilder }
/**
* Define the `profiles.yaml` schema
*/
@@ -1443,6 +1483,7 @@ export type ScriptProfileBuilder = {
}
export type ScriptType = 'javascript' | 'lua'
export type ServiceStatus = 'not_installed' | 'stopped' | 'running'
export type StatisticWidgetVariant = 'large' | 'small'
export type StatusInfo = {
name: string
version: string

View File

@@ -0,0 +1,80 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { unwrapResult } from '../utils'
import { commands } from './bindings'
/**
* A custom hook that manages profile content data fetching and updating.
*
* @remarks
* This hook provides functionality to read and write profile content using React Query.
* It includes both query and mutation capabilities for profile data management.
*
* @param uid - The unique identifier for the profile
*
* @returns An object containing:
* - `query` - The React Query result object for fetching profile content
* - `upsert` - Mutation object for saving/updating profile content
*
* @example
* ```tsx
* const { query, upsert } = useProfileContent("user123");
* const { data, isLoading } = query;
*
* // To update profile content
* upsert.mutate("new profile content");
* ```
*/
export const useProfileContent = (uid: string) => {
const queryClient = useQueryClient()
/**
* A React Query hook that fetches profile content based on a user ID.
*
* @remarks
* This query uses the `readProfileFile` command to retrieve profile data
* and unwraps the result.
*
* @param uid - The user ID used to fetch the profile content
* @returns A React Query result object containing the profile content data,
* loading state, and error state
*
* @example
* ```tsx
* const { data, isLoading } = useQuery(['profileContent', userId]);
* ```
*/
const query = useQuery({
queryKey: ['profileContent', uid],
queryFn: async () => {
return unwrapResult(await commands.readProfileFile(uid))
},
})
/**
* Mutation hook for saving and updating profile file data
*
* @remarks
* This mutation will invalidate the profile content query cache on success
*
* @example
* ```ts
* const { mutate } = upsert;
* mutate("profile content");
* ```
*
* @returns A mutation object that handles saving profile file data
*/
const upsert = useMutation({
mutationFn: async (fileData: string) => {
return unwrapResult(await commands.saveProfileFile(uid, fileData))
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profileContent', uid] })
},
})
return {
query,
upsert,
}
}

View File

@@ -0,0 +1,227 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { unwrapResult } from '../utils'
import { commands, ProfileBuilder, ProfilesBuilder } from './bindings'
type URLImportParams = Parameters<typeof commands.importProfile>
type ManualImportParams = Parameters<typeof commands.createProfile>
type CreateParams =
| {
type: 'url'
data: {
url: URLImportParams[0]
option: URLImportParams[1]
}
}
| {
type: 'manual'
data: {
item: ManualImportParams[0]
fileData: ManualImportParams[1]
}
}
/**
* A custom hook for managing profile operations using React Query.
* Provides functionality for CRUD operations on profiles including creation,
* updating, reordering, and deletion.
*
* @returns An object containing:
* - query: {@link UseQueryResult} Hook result for fetching profiles data
* - create: {@link UseMutationResult} Mutation for creating/importing profiles
* - update: {@link UseMutationResult} Mutation for updating existing profiles
* - sort: {@link UseMutationResult} Mutation for reordering profiles
* - upsert: {@link UseMutationResult} Mutation for upserting profile configurations
* - drop: {@link UseMutationResult} Mutation for deleting profiles
*
* @example
* ```typescript
* const { query, create, update, sort, upsert, drop } = useProfile();
*
* // Fetch profiles
* const { data, isLoading } = query;
*
* // Create a new profile
* create.mutate({
* type: 'file',
* data: { item: profileData, fileData: 'config' }
* });
*
* // Update a profile
* update.mutate({ uid: 'profile-id', profile: updatedProfile });
*
* // Reorder profiles
* sort.mutate(['uid1', 'uid2', 'uid3']);
*
* // Upsert profile config
* upsert.mutate(profilesConfig);
*
* // Delete a profile
* drop.mutate('profile-id');
* ```
*/
export const useProfile = () => {
const queryClient = useQueryClient()
/**
* A React Query hook that fetches profiles data.
* data is the full Profile configuration, including current, chain, valid, and items fields
* Uses the `getProfiles` command to retrieve profile information.
*
* @returns {UseQueryResult} A query result object containing:
* - data: {
* current: string | null - Currently selected profile UID
* chain: string[] - Global chain of profile UIDs
* valid: boolean - Whether the profile configuration is valid
* items: Profile[] - Array of profile configurations
* }
* - `isLoading`: Boolean indicating if the query is in loading state
* - `error`: Error object if the query failed
* - Other standard React Query result properties
*/
const query = useQuery({
queryKey: ['profiles'],
queryFn: async () => {
return unwrapResult(await commands.getProfiles())
},
})
/**
* Mutation hook for creating or importing profiles
*
* @remarks
* This mutation handles two types of profile creation:
* 1. URL-based import using `importProfile` command
* 2. Direct creation using `createProfile` command
*
* @returns A mutation object that accepts CreateParams and handles profile creation
*
* @throws Will throw an error if the profile creation/import fails
*
* @example
* ```ts
* const { mutate } = create();
* // Import from URL
* mutate({ type: 'url', data: { url: 'https://example.com/config.yaml', option: {...} }});
* // Create directly
* mutate({ type: 'file', data: { item: {...}, fileData: '...' }});
* ```
*/
const create = useMutation({
mutationFn: async ({ type, data }: CreateParams) => {
if (type === 'url') {
const { url, option } = data
return unwrapResult(await commands.importProfile(url, option))
} else {
const { item, fileData } = data
return unwrapResult(await commands.createProfile(item, fileData))
}
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['profiles'] })
},
})
/**
* Mutation hook for updating a profile.
* Uses React Query's useMutation to handle profile updates.
*
* @remarks
* This mutation will automatically invalidate and refetch the 'profiles' query on success
*
* @param uid - The unique identifier of the profile to update
* @param profile - The profile data of type ProfileBuilder to update with
*
* @returns A mutation object containing mutate function and mutation state
*
* @throws Will throw an error if the profile update fails
*/
const update = useMutation({
mutationFn: async ({
uid,
profile,
}: {
uid: string
profile: ProfileBuilder
}) => {
return unwrapResult(await commands.patchProfile(uid, profile))
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['profiles'] })
},
})
/**
* Mutation hook for reordering profiles.
* Uses the React Query's useMutation hook to handle profile reordering operations.
*
* @remarks
* This mutation takes an array of profile UIDs and reorders them according to the new sequence.
* On successful reordering, it invalidates the 'profiles' query cache to trigger a refresh.
*
* @example
* ```typescript
* const { mutate } = sort;
* mutate(['uid1', 'uid2', 'uid3']);
* ```
*/
const sort = useMutation({
mutationFn: async (uids: string[]) => {
return unwrapResult(await commands.reorderProfilesByList(uids))
},
onSuccess: () => {
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: ['profiles'] })
},
})
/**
* Mutation hook for upserting profile configurations.
*
* @remarks
* This mutation handles the update/insert of profile configurations and invalidates
* the profiles query cache on success.
*
* @returns A mutation object that:
* - Accepts a ProfilesBuilder parameter for the mutation
* - Returns the unwrapped result from patchProfilesConfig command
* - Automatically invalidates the 'profiles' query cache on successful mutation
*/
const upsert = useMutation({
mutationFn: async (options: ProfilesBuilder) => {
return unwrapResult(await commands.patchProfilesConfig(options))
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] })
},
})
/**
* A mutation hook for deleting a profile.
*
* @returns {UseMutationResult} A mutation object that:
* - Accepts a profile UID as parameter
* - Deletes the profile via commands.deleteProfile
* - Automatically invalidates 'profiles' queries on success
*/
const drop = useMutation({
mutationFn: async (uid: string) => {
return unwrapResult(await commands.deleteProfile(uid))
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] })
},
})
return {
query,
create,
update,
sort,
upsert,
drop,
}
}

View File

@@ -0,0 +1,10 @@
import type { PropsWithChildren } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient()
export const NyanpasuProvider = ({ children }: PropsWithChildren) => {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}

View File

@@ -0,0 +1,8 @@
import type { Result } from '../ipc/bindings'
export function unwrapResult<T, E>(res: Result<T, E>) {
if (res.status === 'error') {
throw res.error
}
return res.status === 'ok' ? res.data : undefined
}

View File

@@ -30,7 +30,7 @@
"dayjs": "1.11.13",
"framer-motion": "12.0.6",
"i18next": "24.2.2",
"jotai": "2.11.1",
"jotai": "2.11.3",
"json-schema": "0.4.0",
"material-react-table": "3.1.0",
"monaco-editor": "0.52.2",
@@ -45,19 +45,20 @@
"react-split-grid": "1.0.4",
"react-use": "17.6.0",
"swr": "2.3.0",
"virtua": "0.39.3",
"virtua": "0.40.0",
"vite-bundle-visualizer": "1.2.1"
},
"devDependencies": {
"@csstools/normalize.css": "12.1.1",
"@emotion/babel-plugin": "11.13.5",
"@emotion/react": "11.14.0",
"@iconify/json": "2.2.301",
"@iconify/json": "2.2.302",
"@monaco-editor/react": "4.6.0",
"@tanstack/react-query": "5.64.2",
"@tanstack/react-router": "1.97.17",
"@tanstack/router-devtools": "1.97.17",
"@tanstack/router-plugin": "1.97.17",
"@tailwindcss/vite": "4.0.3",
"@tanstack/react-query": "5.66.0",
"@tanstack/react-router": "1.99.0",
"@tanstack/router-devtools": "1.99.0",
"@tanstack/router-plugin": "1.99.0",
"@tauri-apps/plugin-clipboard-manager": "2.2.1",
"@tauri-apps/plugin-dialog": "2.2.0",
"@tauri-apps/plugin-fs": "2.2.0",
@@ -65,7 +66,7 @@
"@tauri-apps/plugin-os": "2.2.0",
"@tauri-apps/plugin-process": "2.2.0",
"@tauri-apps/plugin-shell": "2.2.0",
"@tauri-apps/plugin-updater": "2.3.1",
"@tauri-apps/plugin-updater": "2.4.0",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
"@types/validator": "13.12.2",
@@ -80,7 +81,7 @@
"monaco-yaml": "5.2.3",
"nanoid": "5.0.9",
"sass-embedded": "1.83.4",
"shiki": "1.29.2",
"shiki": "2.2.0",
"tailwindcss-textshadow": "2.1.3",
"unplugin-auto-import": "19.0.0",
"unplugin-icons": "22.0.0",

View File

@@ -4,6 +4,5 @@ export default {
'postcss-import': {},
'postcss-html': {},
autoprefixer: {},
tailwindcss: {},
},
}

View File

@@ -1,3 +1,5 @@
@tailwind base;
@tailwind components;
/* stylelint-disable import-notation */
@import 'tailwindcss';
@tailwind utilities;

View File

@@ -74,12 +74,12 @@ export const AppContainer = ({
<div className={styles.container}>
{OS === 'windows' && (
<LayoutControl className="!z-top fixed right-4 top-2" />
<LayoutControl className="!z-top fixed top-2 right-4" />
)}
{/* TODO: add a framer motion animation to toggle the maximized state */}
{OS === 'macos' && !isMaximized && (
<div
className="z-top fixed left-4 top-3 h-8 w-[4.5rem] rounded-full"
className="z-top fixed top-3 left-4 h-8 w-[4.5rem] rounded-full"
style={{ backgroundColor: alpha(palette.primary.main, 0.1) }}
/>
)}

View File

@@ -17,7 +17,7 @@ export const AppDrawer = () => {
<div
className={cn(
'fixed z-10 flex items-center gap-2',
OS === 'macos' ? 'left-24 top-3' : 'left-4 top-1.5',
OS === 'macos' ? 'top-3 left-24' : 'top-1.5 left-4',
)}
data-tauri-drag-region
>

View File

@@ -40,7 +40,7 @@ export const DrawerContent = ({
{!onlyIcon && (
<div
className="mt-1 flex-1 whitespace-pre-wrap text-lg font-bold"
className="mt-1 flex-1 text-lg font-bold whitespace-pre-wrap"
data-tauri-drag-region
>
{'Clash\nNyanpasu'}
@@ -48,7 +48,7 @@ export const DrawerContent = ({
)}
</div>
<div className="scrollbar-hidden flex flex-col gap-2 overflow-y-auto !overflow-x-hidden">
<div className="scrollbar-hidden flex flex-col gap-2 !overflow-x-hidden overflow-y-auto">
{Object.entries(routes).map(([name, { path, icon }]) => {
return (
<RouteListItem

View File

@@ -73,7 +73,7 @@ export const RouteListItem = ({
{!onlyIcon && (
<div
className={cn(
'w-full text-nowrap pb-1 pt-1',
'w-full pt-1 pb-1 text-nowrap',
nyanpasuConfig?.language &&
languageQuirks[nyanpasuConfig?.language].drawer.itemClassNames,
)}

View File

@@ -49,7 +49,7 @@ export const IPASNPanel = ({ refreshCount }: { refreshCount: number }) => {
xl: 3,
}}
>
<Paper className="relative flex !h-full select-text gap-4 !rounded-3xl px-4 py-3">
<Paper className="relative flex !h-full gap-4 !rounded-3xl px-4 py-3 select-text">
{data ? (
<>
{data.country_code && (
@@ -102,7 +102,7 @@ export const IPASNPanel = ({ refreshCount }: { refreshCount: number }) => {
<span
className={cn(
'absolute left-0 top-0 block h-full w-full rounded-lg bg-slate-300 transition-opacity',
'absolute top-0 left-0 block h-full w-full rounded-lg bg-slate-300 transition-opacity',
showIPAddress ? 'opacity-0' : 'animate-pulse opacity-100',
)}
/>
@@ -120,7 +120,7 @@ export const IPASNPanel = ({ refreshCount }: { refreshCount: number }) => {
</>
) : (
<>
<div className="mb-2 mt-1.5 h-9 w-12 animate-pulse rounded-lg bg-slate-700" />
<div className="mt-1.5 mb-2 h-9 w-12 animate-pulse rounded-lg bg-slate-700" />
<div className="flex flex-1 animate-pulse flex-col gap-1">
<div className="mt-1.5 h-6 w-20 rounded-full bg-slate-700" />

View File

@@ -22,7 +22,7 @@ export const LogItem = ({ value }: { value: LogMessage }) => {
}, [value.payload])
return (
<div className="w-full select-text p-4 pb-0 pt-2 font-mono">
<div className="w-full p-4 pt-2 pb-0 font-mono select-text">
<div className="flex gap-2">
<span className="font-thin">{value.time}</span>
@@ -36,7 +36,7 @@ export const LogItem = ({ value }: { value: LogMessage }) => {
</span>
</div>
<div className="text-wrap border-b border-slate-200 pb-2">
<div className="border-b border-slate-200 pb-2 text-wrap">
<p
className={cn(
styles.item,

View File

@@ -88,7 +88,7 @@ export const ChainItem = memo(function ChainItem({
}}
>
<ListItemButton
className="!mb-2 !mt-2 !flex !justify-between gap-2"
className="!mt-2 !mb-2 !flex !justify-between gap-2"
sx={[
{
borderRadius: 4,

View File

@@ -68,7 +68,7 @@ export const SideChain = ({ onChainEdit }: SideChainProps) => {
)
return (
<div className="h-full overflow-auto !pl-2 !pr-2">
<div className="h-full overflow-auto !pr-2 !pl-2">
<Reorder.Group
axis="y"
values={reorderValues}
@@ -97,7 +97,7 @@ export const SideChain = ({ onChainEdit }: SideChainProps) => {
</Reorder.Group>
<ListItemButton
className="!mb-2 !mt-2 flex justify-center gap-2"
className="!mt-2 !mb-2 flex justify-center gap-2"
sx={{
backgroundColor: alpha(palette.secondary.main, 0.1),
borderRadius: 4,

View File

@@ -21,7 +21,7 @@ const LogListItem = memo(function LogListItem({
<>
{showDivider && <Divider />}
<div className="w-full break-all font-mono">
<div className="w-full font-mono break-all">
<span className="rounded-sm bg-blue-600 px-0.5">{name}</span>
<span className="text-red-500"> [{item?.[0]}]: </span>
<span>{item?.[1]}</span>
@@ -53,7 +53,7 @@ export const SideLog = ({ className }: SideLogProps) => {
<Divider />
<VList className="flex select-text flex-col gap-2 overflow-auto p-2">
<VList className="flex flex-col gap-2 overflow-auto p-2 select-text">
{!isEmpty(getRuntimeLogs.data) ? (
Object.entries(getRuntimeLogs.data).map(([uid, content]) => {
return content.map((item, index) => {

View File

@@ -170,7 +170,7 @@ export const ProfileDialog = ({
const MetaInfo = useMemo(
() => (
<div className="flex flex-col gap-4 pb-2 pt-2">
<div className="flex flex-col gap-4 pt-2 pb-2">
{!isEdit && (
<SelectElement
label={t('Type')}

View File

@@ -234,7 +234,7 @@ export const ProfileItem = memo(function ProfileItem({
<div className="flex items-center justify-between gap-2">
<Tooltip title={item.url}>
<Chip
className="!pl-2 !pr-2 font-bold"
className="!pr-2 !pl-2 font-bold"
avatar={<IconComponent className="!size-5" color="primary" />}
label={isRemote ? t('Remote') : t('Local')}
/>
@@ -250,7 +250,7 @@ export const ProfileItem = memo(function ProfileItem({
)}
<TextCarousel
className="w-30 flex h-6 items-center"
className="flex h-6 w-30 items-center"
nodes={[
!!item.updated && (
<TimeSpan ts={item.updated!} k="Subscription Updated At" />
@@ -348,7 +348,7 @@ export const ProfileItem = memo(function ProfileItem({
<motion.div
className={cn(
'absolute left-0 top-0 h-full w-full',
'absolute top-0 left-0 h-full w-full',
'flex-col items-center justify-center gap-4',
'text-shadow-xl rounded-3xl font-bold backdrop-blur',
)}
@@ -379,7 +379,7 @@ function TimeSpan({ ts, k }: { ts: number; k: string }) {
const { t } = useTranslation()
return (
<Tooltip title={time.format('YYYY/MM/DD HH:mm:ss')}>
<div className="animate-marquee h-fit whitespace-nowrap text-right text-sm font-medium">
<div className="animate-marquee h-fit text-right text-sm font-medium whitespace-nowrap">
{t(k, {
time: time.fromNow(),
})}

View File

@@ -192,8 +192,8 @@ export const ScriptDialog = ({
{...props}
>
<div className="flex h-full">
<div className="overflow-auto pb-4 pt-4">
<div className="flex flex-col gap-4 pb-4 pl-4 pr-4">
<div className="overflow-auto pt-4 pb-4">
<div className="flex flex-col gap-4 pr-4 pb-4 pl-4">
{!isEdit && (
<SelectElement
label={t('Type')}

View File

@@ -47,7 +47,7 @@ export const DelayButton = memo(function DelayButton({
return (
<Tooltip title={t('Delay check')}>
<Button
className="!fixed bottom-8 right-8 z-10 size-16 !rounded-2xl backdrop-blur"
className="!fixed right-8 bottom-8 z-10 size-16 !rounded-2xl backdrop-blur"
sx={{
boxShadow: 8,
backgroundColor: alpha(

View File

@@ -37,7 +37,7 @@ const RuleItem = ({ index, value }: Props) => {
}
return (
<div className="flex select-text p-2 pl-7 pr-7">
<div className="flex p-2 pr-7 pl-7 select-text">
<div style={{ color: palette.text.secondary }} className="min-w-14">
{index + 1}
</div>

View File

@@ -71,7 +71,7 @@ const CardProgress = ({
return (
<motion.div
className={cn(
'absolute left-0 top-0 z-10 h-full w-full rounded-2xl backdrop-blur',
'absolute top-0 left-0 z-10 h-full w-full rounded-2xl backdrop-blur',
'flex flex-col items-center justify-center gap-2',
)}
style={{

View File

@@ -44,7 +44,7 @@ export default function HotkeyInput({
<div className={cn('relative min-h-[36px] w-[165px]', styles.wrapper)}>
<input
className={cn(
'absolute left-0 top-0 z-[1] h-full w-full opacity-0',
'absolute top-0 left-0 z-[1] h-full w-full opacity-0',
styles.input,
className,
)}

View File

@@ -37,7 +37,7 @@ function CopyToClipboardButton({ onClick }: CopyToClipboardButtonProps) {
>
<IconButton
size="small"
className="absolute right-1 top-1"
className="absolute top-1 right-1"
onClick={onClick}
>
<ContentPasteIcon fontSize="small" color="primary" />

View File

@@ -112,7 +112,7 @@ export const SettingNyanpasuVersion = () => {
{isPlatformSupported && (
<>
<div className="mb-1 mt-1">
<div className="mt-1 mb-1">
<AutoCheckUpdate />
</div>
<ListItem sx={{ pl: 0, pr: 0 }}>

View File

@@ -235,9 +235,9 @@ function ProfilePage() {
</AnimatePresence>
<AddProfileContext.Provider value={addProfileCtxValue}>
<div className="fixed bottom-8 right-8">
<div className="fixed right-8 bottom-8">
<FloatingButton
className="relative -right-2.5 -top-3 flex size-11 min-w-fit"
className="relative -top-3 -right-2.5 flex size-11 min-w-fit"
sx={[
(theme) => ({
backgroundColor: theme.palette.grey[200],

View File

@@ -114,7 +114,7 @@ function ProxyPage() {
onClick={() => handleSwitch(key)}
sx={{ textTransform: 'capitalize' }}
>
{enabled && <Check className="-ml-2 mr-[0.1rem] scale-75" />}
{enabled && <Check className="mr-[0.1rem] -ml-2 scale-75" />}
{t(key)}
</Button>
))}

View File

@@ -1,5 +1,6 @@
import type { Highlighter } from 'shiki'
import { getSingletonHighlighterCore } from 'shiki/core'
import { createOnigurumaEngine } from 'shiki/engine/oniguruma'
import minLight from 'shiki/themes/min-light.mjs'
import nord from 'shiki/themes/nord.mjs'
import getWasm from 'shiki/wasm'
@@ -9,6 +10,7 @@ let shiki: Highlighter | null = null
export async function getShikiSingleton() {
if (!shiki) {
shiki = (await getSingletonHighlighterCore({
engine: createOnigurumaEngine(import('shiki/wasm')),
themes: [nord, minLight],
langs: [() => import('shiki/langs/shell.mjs')],
loadWasm: getWasm,

View File

@@ -8,6 +8,7 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import sassDts from 'vite-plugin-sass-dts'
import svgr from 'vite-plugin-svgr'
import tsconfigPaths from 'vite-tsconfig-paths'
import tailwindPlugin from '@tailwindcss/vite'
// import react from "@vitejs/plugin-react";
import { TanStackRouterVite } from '@tanstack/router-plugin/vite'
import legacy from '@vitejs/plugin-legacy'
@@ -56,6 +57,7 @@ export default defineConfig(({ command, mode }) => {
},
},
plugins: [
tailwindPlugin(),
tsconfigPaths(),
legacy({
renderLegacyChunks: false,

View File

@@ -43,7 +43,7 @@
"clsx": "2.1.1",
"d3-interpolate-path": "2.3.0",
"sass-embedded": "1.83.4",
"tailwind-merge": "2.6.0",
"tailwind-merge": "3.0.1",
"typescript-plugin-css-modules": "5.1.0",
"vite-plugin-dts": "4.5.0"
}

View File

@@ -8,6 +8,6 @@
justify-content: center;
width: 100%;
height: 100%;
backdrop-filter: blur(4px);
border-radius: 24px;
backdrop-filter: blur(4px);
}

View File

@@ -125,7 +125,7 @@ export const BaseDialog = ({
return (
<AnimatePresence initial={false}>
{mounted && (
<Portal.Root className="fixed left-0 top-0 z-50 h-dvh w-full">
<Portal.Root className="fixed top-0 left-0 z-50 h-dvh w-full">
{!full && (
<motion.div
className={cn(
@@ -150,7 +150,7 @@ export const BaseDialog = ({
<motion.div
className={cn(
'fixed left-[50%] top-[50%] z-50',
'fixed top-[50%] left-[50%] z-50',
full ? 'h-dvh w-full' : 'min-w-96 rounded-3xl shadow',
palette.mode === 'dark'
? 'text-white shadow-zinc-900'
@@ -202,7 +202,7 @@ export const BaseDialog = ({
<div
className={cn(
'relative overflow-y-auto overflow-x-hidden p-4',
'relative overflow-x-hidden overflow-y-auto p-4',
full && 'h-full px-6',
)}
style={{

View File

@@ -9,7 +9,7 @@ export const Header: FC<{ title?: ReactNode; header?: ReactNode }> = memo(
header?: ReactNode
}) {
return (
<header className="select-none pl-2" data-tauri-drag-region>
<header className="pl-2 select-none" data-tauri-drag-region>
<h1 className="mb-1 text-4xl font-medium" data-tauri-drag-region>
{title}
</h1>

View File

@@ -50,7 +50,7 @@ export const BasePage: FC<BasePageProps> = ({
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex touch-none select-none py-6 pr-1.5"
className="flex touch-none py-6 pr-1.5 select-none"
orientation="vertical"
>
<ScrollArea.Thumb className="ScrollArea-Thumb relative flex !w-1.5 flex-1 rounded-full" />

View File

@@ -17,7 +17,7 @@ export const FloatingButton = ({
return (
<Button
className={cn(
`bottom-8 right-8 z-10 size-16 !rounded-2xl backdrop-blur`,
`right-8 bottom-8 z-10 size-16 !rounded-2xl backdrop-blur`,
className,
)}
sx={{

View File

@@ -89,7 +89,7 @@ export const SidePage: FC<Props> = ({
<ScrollArea.Scrollbar
className={cn(
'flex touch-none select-none py-6 pr-1.5',
'flex touch-none py-6 pr-1.5 select-none',
sideBar && '!top-14',
)}
orientation="vertical"
@@ -120,7 +120,7 @@ export const SidePage: FC<Props> = ({
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
className="flex touch-none select-none py-6 pr-1.5"
className="flex touch-none py-6 pr-1.5 select-none"
orientation="vertical"
>
<ScrollArea.Thumb className="!bg-scroller relative flex !w-1.5 flex-1 rounded-full" />

View File

@@ -59,21 +59,21 @@
"devDependencies": {
"@commitlint/cli": "19.6.1",
"@commitlint/config-conventional": "19.6.0",
"@eslint/compat": "1.2.5",
"@eslint/compat": "1.2.6",
"@eslint/eslintrc": "3.2.0",
"@ianvs/prettier-plugin-sort-imports": "4.4.1",
"@tauri-apps/cli": "2.2.5",
"@tauri-apps/cli": "2.2.7",
"@types/fs-extra": "11.0.4",
"@types/lodash-es": "4.17.12",
"@types/node": "22.10.10",
"@typescript-eslint/eslint-plugin": "8.21.0",
"@typescript-eslint/parser": "8.21.0",
"@types/node": "22.13.0",
"@typescript-eslint/eslint-plugin": "8.22.0",
"@typescript-eslint/parser": "8.22.0",
"autoprefixer": "10.4.20",
"conventional-changelog-conventionalcommits": "8.0.0",
"cross-env": "7.0.3",
"dedent": "1.5.3",
"eslint": "9.19.0",
"eslint-config-prettier": "9.1.0",
"eslint-config-prettier": "10.0.1",
"eslint-config-standard": "17.1.0",
"eslint-import-resolver-alias": "1.1.2",
"eslint-plugin-html": "8.1.2",
@@ -86,7 +86,7 @@
"eslint-plugin-react-hooks": "5.1.0",
"globals": "15.14.0",
"knip": "5.43.6",
"lint-staged": "15.4.2",
"lint-staged": "15.4.3",
"neostandard": "0.12.0",
"npm-run-all2": "7.0.2",
"postcss": "8.5.1",
@@ -96,18 +96,18 @@
"prettier": "3.4.2",
"prettier-plugin-tailwindcss": "0.6.11",
"prettier-plugin-toml": "2.0.1",
"react-devtools": "6.0.1",
"stylelint": "16.13.2",
"react-devtools": "6.1.0",
"stylelint": "16.14.1",
"stylelint-config-html": "1.1.0",
"stylelint-config-recess-order": "5.1.1",
"stylelint-config-standard": "36.0.1",
"stylelint-config-recess-order": "6.0.0",
"stylelint-config-standard": "37.0.0",
"stylelint-declaration-block-no-ignored-properties": "2.8.0",
"stylelint-order": "6.0.4",
"stylelint-scss": "6.10.1",
"tailwindcss": "3.4.17",
"stylelint-scss": "6.11.0",
"tailwindcss": "4.0.3",
"tsx": "4.19.2",
"typescript": "5.7.3",
"typescript-eslint": "8.21.0"
"typescript-eslint": "8.22.0"
},
"packageManager": "pnpm@10.1.0",
"engines": {

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@
"p-retry": "6.2.1"
},
"devDependencies": {
"@octokit/types": "13.7.0",
"@octokit/types": "13.8.0",
"@types/adm-zip": "0.5.7",
"adm-zip": "0.5.16",
"colorize-template": "1.0.0",

View File

@@ -0,0 +1,57 @@
From 4e432e530db0056450fbc4a3cee793f16adc39a7 Mon Sep 17 00:00:00 2001
From: Daniel Golle <daniel@makrotopia.org>
Date: Tue, 8 Oct 2024 23:58:41 +0100
Subject: [PATCH] net: phy: populate host_interfaces when attaching PHY
Use bitmask of interfaces supported by the MAC for the PHY to choose
from if the declared interface mode is among those using a single pair
of SerDes lanes.
This will allow 2500Base-T PHYs to switch to SGMII on most hosts, which
results in half-duplex being supported in case the MAC supports them.
Without this change, 2500Base-T PHYs will always operate in 2500Base-X
mode with rate-matching, which is not only wasteful in terms of energy
consumption, but also limits the supported interface modes to
full-duplex only.
Signed-off-by: Daniel Golle <daniel@makrotopia.org>
---
drivers/net/phy/phylink.c | 21 ++++++++++++++++++++-
1 file changed, 20 insertions(+), 1 deletion(-)
--- a/drivers/net/phy/phylink.c
+++ b/drivers/net/phy/phylink.c
@@ -2017,7 +2017,7 @@ int phylink_fwnode_phy_connect(struct ph
{
struct fwnode_handle *phy_fwnode;
struct phy_device *phy_dev;
- int ret;
+ int i, ret;
/* Fixed links and 802.3z are handled without needing a PHY */
if (pl->cfg_link_an_mode == MLO_AN_FIXED ||
@@ -2044,6 +2044,25 @@ int phylink_fwnode_phy_connect(struct ph
pl->link_config.interface = pl->link_interface;
}
+ /* Assume single-lane SerDes interface modes share the same
+ * lanes and allow the PHY to switch to slower also supported modes
+ */
+ for (i = ARRAY_SIZE(phylink_sfp_interface_preference) - 1; i >= 0; i--) {
+ /* skip unsupported modes */
+ if (!test_bit(phylink_sfp_interface_preference[i], pl->config->supported_interfaces))
+ continue;
+
+ __set_bit(phylink_sfp_interface_preference[i], phy_dev->host_interfaces);
+
+ /* skip all faster modes */
+ if (phylink_sfp_interface_preference[i] == pl->link_interface)
+ break;
+ }
+
+ if (test_bit(pl->link_interface, phylink_sfp_interfaces))
+ phy_interface_and(phy_dev->host_interfaces, phylink_sfp_interfaces,
+ pl->config->supported_interfaces);
+
ret = phy_attach_direct(pl->netdev, phy_dev, flags,
pl->link_interface);
phy_device_free(phy_dev);

View File

@@ -352,15 +352,15 @@ o:value("119.28.28.28")
o:depends("direct_dns_mode", "tcp")
o = s:taboption("DNS", Value, "direct_dns_dot", translate("Direct DNS DoT"))
o.default = "tls://dot.pub@1.12.12.12"
o:value("tls://dot.pub@1.12.12.12")
o:value("tls://dot.pub@120.53.53.53")
o:value("tls://dot.360.cn@36.99.170.86")
o:value("tls://dot.360.cn@101.198.191.4")
o:value("tls://dns.alidns.com@223.5.5.5")
o:value("tls://dns.alidns.com@223.6.6.6")
o:value("tls://dns.alidns.com@2400:3200::1")
o:value("tls://dns.alidns.com@2400:3200:baba::1")
o.default = "tls://1.12.12.12"
o:value("tls://1.12.12.12")
o:value("tls://120.53.53.53")
o:value("tls://36.99.170.86")
o:value("tls://101.198.191.4")
o:value("tls://223.5.5.5")
o:value("tls://223.6.6.6")
o:value("tls://2400:3200::1")
o:value("tls://2400:3200:baba::1")
o.validate = chinadns_dot_validate
o:depends("direct_dns_mode", "dot")
@@ -502,17 +502,17 @@ o:depends({singbox_dns_mode = "tcp"})
---- DoT
o = s:taboption("DNS", Value, "remote_dns_dot", translate("Remote DNS DoT"))
o.default = "tls://dns.google@8.8.4.4"
o:value("tls://1dot1dot1dot1.cloudflare-dns.com@1.0.0.1", "1.0.0.1 (CloudFlare)")
o:value("tls://1dot1dot1dot1.cloudflare-dns.com@1.1.1.1", "1.1.1.1 (CloudFlare)")
o:value("tls://dns.google@8.8.4.4", "8.8.4.4 (Google)")
o:value("tls://dns.google@8.8.8.8", "8.8.8.8 (Google)")
o:value("tls://dns.quad9.net@9.9.9.9", "9.9.9.9 (Quad9)")
o:value("tls://dns.quad9.net@149.112.112.112", "149.112.112.112 (Quad9)")
o:value("tls://dns.adguard.com@94.140.14.14", "94.140.14.14 (AdGuard)")
o:value("tls://dns.adguard.com@94.140.15.15", "94.140.15.15 (AdGuard)")
o:value("tls://dns.opendns.com@208.67.222.222", "208.67.222.222 (OpenDNS)")
o:value("tls://dns.opendns.com@208.67.220.220", "208.67.220.220 (OpenDNS)")
o.default = "tls://1.1.1.1"
o:value("tls://1.0.0.1", "1.0.0.1 (CloudFlare)")
o:value("tls://1.1.1.1", "1.1.1.1 (CloudFlare)")
o:value("tls://8.8.4.4", "8.8.4.4 (Google)")
o:value("tls://8.8.8.8", "8.8.8.8 (Google)")
o:value("tls://9.9.9.9", "9.9.9.9 (Quad9)")
o:value("tls://149.112.112.112", "149.112.112.112 (Quad9)")
o:value("tls://94.140.14.14", "94.140.14.14 (AdGuard)")
o:value("tls://94.140.15.15", "94.140.15.15 (AdGuard)")
o:value("tls://208.67.222.222", "208.67.222.222 (OpenDNS)")
o:value("tls://208.67.220.220", "208.67.220.220 (OpenDNS)")
o.validate = chinadns_dot_validate
o:depends("dns_mode", "dot")

View File

@@ -718,7 +718,7 @@ function gen_config(var)
local blc_node_tag = "blc-" .. blc_node_id
local is_new_blc_node = true
for _, outbound in ipairs(outbounds) do
if outbound.tag:find("^" .. blc_node_tag) == 1 then
if string.sub(outbound.tag, 1, #blc_node_tag) == blc_node_tag then
is_new_blc_node = false
valid_nodes[#valid_nodes + 1] = outbound.tag
break
@@ -743,7 +743,7 @@ function gen_config(var)
if fallback_node_id then
local is_new_node = true
for _, outbound in ipairs(outbounds) do
if outbound.tag:find("^" .. fallback_node_id) == 1 then
if string.sub(outbound.tag, 1, #fallback_node_id) == fallback_node_id then
is_new_node = false
fallback_node_tag = outbound.tag
break

View File

@@ -919,7 +919,7 @@ run_redir() {
_args="${_args} direct_dns_tcp_server=$(config_t_get global direct_dns_tcp 223.5.5.5 | sed 's/:/#/g')"
;;
dot)
local tmp_dot_dns=$(config_t_get global direct_dns_dot "tls://dot.pub@1.12.12.12")
local tmp_dot_dns=$(config_t_get global direct_dns_dot "tls://1.12.12.12")
local tmp_dot_ip=$(echo "$tmp_dot_dns" | sed -n 's/.*:\/\/\([^@#]*@\)*\([^@#]*\).*/\2/p')
local tmp_dot_port=$(echo "$tmp_dot_dns" | sed -n 's/.*#\([0-9]\+\).*/\1/p')
_args="${_args} direct_dns_dot_server=$tmp_dot_ip#${tmp_dot_port:-853}"
@@ -1397,7 +1397,7 @@ start_dns() {
;;
dot)
if [ "$chinadns_tls" != "nil" ]; then
local DIRECT_DNS=$(config_t_get global direct_dns_dot "tls://dot.pub@1.12.12.12")
local DIRECT_DNS=$(config_t_get global direct_dns_dot "tls://1.12.12.12")
china_ng_local_dns=${DIRECT_DNS}
#当全局包括访问控制节点开启chinadns-ng时不启动新进程。
@@ -1519,7 +1519,7 @@ start_dns() {
TCP_PROXY_DNS=1
if [ "$chinadns_tls" != "nil" ]; then
local china_ng_listen_port=${NEXT_DNS_LISTEN_PORT}
local china_ng_trust_dns=$(config_t_get global remote_dns_dot "tls://dns.google@8.8.4.4")
local china_ng_trust_dns=$(config_t_get global remote_dns_dot "tls://1.1.1.1")
local tmp_dot_ip=$(echo "$china_ng_trust_dns" | sed -n 's/.*:\/\/\([^@#]*@\)*\([^@#]*\).*/\2/p')
local tmp_dot_port=$(echo "$china_ng_trust_dns" | sed -n 's/.*#\([0-9]\+\).*/\1/p')
REMOTE_DNS="$tmp_dot_ip#${tmp_dot_port:-853}"
@@ -1864,7 +1864,7 @@ acl_app() {
;;
dot)
if [ "$(chinadns-ng -V | grep -i wolfssl)" != "nil" ]; then
_chinadns_local_dns=$(config_t_get global direct_dns_dot "tls://dot.pub@1.12.12.12")
_chinadns_local_dns=$(config_t_get global direct_dns_dot "tls://1.12.12.12")
fi
;;
esac

View File

@@ -9,20 +9,21 @@ probe_file="/tmp/etc/passwall/haproxy/Probe_URL"
probeUrl="https://www.google.com/generate_204"
if [ -f "$probe_file" ]; then
firstLine=$(head -n 1 "$probe_file" | tr -d ' \t')
if [ -n "$firstLine" ]; then
probeUrl="$firstLine"
fi
[ -n "$firstLine" ] && probeUrl="$firstLine"
fi
status=$(/usr/bin/curl -I -o /dev/null -skL -x socks5h://${server_address}:${server_port} --connect-timeout 3 --retry 3 -w %{http_code} "${probeUrl}")
extra_params="-x socks5h://${server_address}:${server_port}"
if /usr/bin/curl --help all | grep -q "\-\-retry-all-errors"; then
extra_params="${extra_params} --retry-all-errors"
fi
status=$(/usr/bin/curl -I -o /dev/null -skL ${extra_params} --connect-timeout 3 --retry 1 -w "%{http_code}" "${probeUrl}")
case "$status" in
204|\
200)
status=200
200|204)
exit 0
;;
*)
exit 1
;;
esac
return_code=1
if [ "$status" = "200" ]; then
return_code=0
fi
exit ${return_code}

View File

@@ -28,9 +28,10 @@ test_url() {
local timeout=2
[ -n "$3" ] && timeout=$3
local extra_params=$4
curl --help all | grep "\-\-retry-all-errors" > /dev/null
[ $? == 0 ] && extra_params="--retry-all-errors ${extra_params}"
status=$(/usr/bin/curl -I -o /dev/null -skL --user-agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" ${extra_params} --connect-timeout ${timeout} --retry ${try} -w %{http_code} "$url")
if /usr/bin/curl --help all | grep -q "\-\-retry-all-errors"; then
extra_params="--retry-all-errors ${extra_params}"
fi
status=$(/usr/bin/curl -I -o /dev/null -skL ${extra_params} --connect-timeout ${timeout} --retry ${try} -w %{http_code} "$url")
case "$status" in
204)
status=200

View File

@@ -50,17 +50,17 @@ type CacheFile interface {
StoreSelected(group string, selected string) error
LoadGroupExpand(group string) (isExpand bool, loaded bool)
StoreGroupExpand(group string, expand bool) error
LoadRuleSet(tag string) *SavedRuleSet
SaveRuleSet(tag string, set *SavedRuleSet) error
LoadRuleSet(tag string) *SavedBinary
SaveRuleSet(tag string, set *SavedBinary) error
}
type SavedRuleSet struct {
type SavedBinary struct {
Content []byte
LastUpdated time.Time
LastEtag string
}
func (s *SavedRuleSet) MarshalBinary() ([]byte, error) {
func (s *SavedBinary) MarshalBinary() ([]byte, error) {
var buffer bytes.Buffer
err := binary.Write(&buffer, binary.BigEndian, uint8(1))
if err != nil {
@@ -81,7 +81,7 @@ func (s *SavedRuleSet) MarshalBinary() ([]byte, error) {
return buffer.Bytes(), nil
}
func (s *SavedRuleSet) UnmarshalBinary(data []byte) error {
func (s *SavedBinary) UnmarshalBinary(data []byte) error {
reader := bytes.NewReader(data)
var version uint8
err := binary.Read(reader, binary.BigEndian, &version)

View File

@@ -30,7 +30,7 @@ func init() {
}
func generateTLSKeyPair(serverName string) error {
privateKeyPem, publicKeyPem, err := tls.GenerateKeyPair(time.Now, serverName, time.Now().AddDate(0, flagGenerateTLSKeyPairMonths, 0))
privateKeyPem, publicKeyPem, err := tls.GenerateCertificate(nil, nil, time.Now, serverName, time.Now().AddDate(0, flagGenerateTLSKeyPairMonths, 0))
if err != nil {
return err
}

View File

@@ -11,8 +11,8 @@ import (
"time"
)
func GenerateCertificate(timeFunc func() time.Time, serverName string) (*tls.Certificate, error) {
privateKeyPem, publicKeyPem, err := GenerateKeyPair(timeFunc, serverName, timeFunc().Add(time.Hour))
func GenerateKeyPair(parent *x509.Certificate, parentKey any, timeFunc func() time.Time, serverName string) (*tls.Certificate, error) {
privateKeyPem, publicKeyPem, err := GenerateCertificate(parent, parentKey, timeFunc, serverName, timeFunc().Add(time.Hour))
if err != nil {
return nil, err
}
@@ -23,7 +23,7 @@ func GenerateCertificate(timeFunc func() time.Time, serverName string) (*tls.Cer
return &certificate, err
}
func GenerateKeyPair(timeFunc func() time.Time, serverName string, expire time.Time) (privateKeyPem []byte, publicKeyPem []byte, err error) {
func GenerateCertificate(parent *x509.Certificate, parentKey any, timeFunc func() time.Time, serverName string, expire time.Time) (privateKeyPem []byte, publicKeyPem []byte, err error) {
if timeFunc == nil {
timeFunc = time.Now
}
@@ -47,7 +47,11 @@ func GenerateKeyPair(timeFunc func() time.Time, serverName string, expire time.T
},
DNSNames: []string{serverName},
}
publicDer, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key)
if parent == nil {
parent = template
parentKey = key
}
publicDer, err := x509.CreateCertificate(rand.Reader, template, parent, key.Public(), parentKey)
if err != nil {
return
}

View File

@@ -222,7 +222,7 @@ func NewSTDServer(ctx context.Context, logger log.Logger, options option.Inbound
}
if certificate == nil && key == nil && options.Insecure {
tlsConfig.GetCertificate = func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
return GenerateCertificate(ntp.TimeFuncFromContext(ctx), info.ServerName)
return GenerateKeyPair(nil, nil, ntp.TimeFuncFromContext(ctx), info.ServerName)
}
} else {
if certificate == nil {

View File

@@ -284,8 +284,8 @@ func (c *CacheFile) StoreGroupExpand(group string, isExpand bool) error {
})
}
func (c *CacheFile) LoadRuleSet(tag string) *adapter.SavedRuleSet {
var savedSet adapter.SavedRuleSet
func (c *CacheFile) LoadRuleSet(tag string) *adapter.SavedBinary {
var savedSet adapter.SavedBinary
err := c.DB.View(func(t *bbolt.Tx) error {
bucket := c.bucket(t, bucketRuleSet)
if bucket == nil {
@@ -303,7 +303,7 @@ func (c *CacheFile) LoadRuleSet(tag string) *adapter.SavedRuleSet {
return &savedSet
}
func (c *CacheFile) SaveRuleSet(tag string, set *adapter.SavedRuleSet) error {
func (c *CacheFile) SaveRuleSet(tag string, set *adapter.SavedBinary) error {
return c.DB.Batch(func(t *bbolt.Tx) error {
bucket, err := c.createBucket(t, bucketRuleSet)
if err != nil {

View File

@@ -7,11 +7,13 @@ var (
type Locale struct {
// deprecated messages for graphical clients
Locale string
DeprecatedMessage string
DeprecatedMessageNoLink string
}
var defaultLocal = &Locale{
Locale: "en_US",
DeprecatedMessage: "%s is deprecated in sing-box %s and will be removed in sing-box %s please checkout documentation for migration.",
DeprecatedMessageNoLink: "%s is deprecated in sing-box %s and will be removed in sing-box %s.",
}

View File

@@ -4,6 +4,7 @@ var warningMessageForEndUsers = "\n\n如果您不明白此消息意味着什么
func init() {
localeRegistry["zh_CN"] = &Locale{
Locale: "zh_CN",
DeprecatedMessage: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除,请参阅迁移指南。" + warningMessageForEndUsers,
DeprecatedMessageNoLink: "%s 已在 sing-box %s 中被弃用,且将在 sing-box %s 中被移除。" + warningMessageForEndUsers,
}

View File

@@ -162,7 +162,7 @@ func (r *Router) Start(stage adapter.StartStage) error {
r.started = true
return nil
case adapter.StartStateStarted:
for _, ruleSet := range r.ruleSetMap {
for _, ruleSet := range r.ruleSets {
ruleSet.Cleanup()
}
runtime.GC()
@@ -180,6 +180,13 @@ func (r *Router) Close() error {
})
monitor.Finish()
}
for i, ruleSet := range r.ruleSets {
monitor.Start("close rule-set[", i, "]")
err = E.Append(err, ruleSet.Close(), func(err error) error {
return E.Cause(err, "close rule-set[", i, "]")
})
monitor.Finish()
}
return err
}

View File

@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/sagernet/fswatch"
"github.com/sagernet/sing-box/adapter"
@@ -26,14 +27,16 @@ import (
var _ adapter.RuleSet = (*LocalRuleSet)(nil)
type LocalRuleSet struct {
ctx context.Context
logger logger.Logger
tag string
rules []adapter.HeadlessRule
metadata adapter.RuleSetMetadata
fileFormat string
watcher *fswatch.Watcher
refs atomic.Int32
ctx context.Context
logger logger.Logger
tag string
rules []adapter.HeadlessRule
metadata adapter.RuleSetMetadata
fileFormat string
watcher *fswatch.Watcher
callbackAccess sync.Mutex
callbacks list.List[adapter.RuleSetUpdateCallback]
refs atomic.Int32
}
func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.RuleSet) (*LocalRuleSet, error) {
@@ -52,13 +55,12 @@ func NewLocalRuleSet(ctx context.Context, logger logger.Logger, options option.R
return nil, err
}
} else {
err := ruleSet.reloadFile(filemanager.BasePath(ctx, options.LocalOptions.Path))
filePath := filemanager.BasePath(ctx, options.LocalOptions.Path)
filePath, _ = filepath.Abs(filePath)
err := ruleSet.reloadFile(filePath)
if err != nil {
return nil, err
}
}
if options.Type == C.RuleSetTypeLocal {
filePath, _ := filepath.Abs(options.LocalOptions.Path)
watcher, err := fswatch.NewWatcher(fswatch.Options{
Path: []string{filePath},
Callback: func(path string) {
@@ -141,6 +143,12 @@ func (s *LocalRuleSet) reloadRules(headlessRules []option.HeadlessRule) error {
metadata.ContainsIPCIDRRule = hasHeadlessRule(headlessRules, isIPCIDRHeadlessRule)
s.rules = rules
s.metadata = metadata
s.callbackAccess.Lock()
callbacks := s.callbacks.Array()
s.callbackAccess.Unlock()
for _, callback := range callbacks {
callback(s)
}
return nil
}
@@ -173,10 +181,15 @@ func (s *LocalRuleSet) Cleanup() {
}
func (s *LocalRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] {
return nil
s.callbackAccess.Lock()
defer s.callbackAccess.Unlock()
return s.callbacks.PushBack(callback)
}
func (s *LocalRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) {
s.callbackAccess.Lock()
defer s.callbackAccess.Unlock()
s.callbacks.Remove(element)
}
func (s *LocalRuleSet) Close() error {

View File

@@ -35,23 +35,23 @@ import (
var _ adapter.RuleSet = (*RemoteRuleSet)(nil)
type RemoteRuleSet struct {
ctx context.Context
cancel context.CancelFunc
outboundManager adapter.OutboundManager
logger logger.ContextLogger
options option.RuleSet
metadata adapter.RuleSetMetadata
updateInterval time.Duration
dialer N.Dialer
rules []adapter.HeadlessRule
lastUpdated time.Time
lastEtag string
updateTicker *time.Ticker
cacheFile adapter.CacheFile
pauseManager pause.Manager
callbackAccess sync.Mutex
callbacks list.List[adapter.RuleSetUpdateCallback]
refs atomic.Int32
ctx context.Context
cancel context.CancelFunc
logger logger.ContextLogger
outbound adapter.OutboundManager
options option.RuleSet
metadata adapter.RuleSetMetadata
updateInterval time.Duration
dialer N.Dialer
rules []adapter.HeadlessRule
lastUpdated time.Time
lastEtag string
updateTicker *time.Ticker
cacheFile adapter.CacheFile
pauseManager pause.Manager
callbackAccess sync.Mutex
callbacks list.List[adapter.RuleSetUpdateCallback]
refs atomic.Int32
}
func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options option.RuleSet) *RemoteRuleSet {
@@ -63,13 +63,13 @@ func NewRemoteRuleSet(ctx context.Context, logger logger.ContextLogger, options
updateInterval = 24 * time.Hour
}
return &RemoteRuleSet{
ctx: ctx,
cancel: cancel,
outboundManager: service.FromContext[adapter.OutboundManager](ctx),
logger: logger,
options: options,
updateInterval: updateInterval,
pauseManager: service.FromContext[pause.Manager](ctx),
ctx: ctx,
cancel: cancel,
outbound: service.FromContext[adapter.OutboundManager](ctx),
logger: logger,
options: options,
updateInterval: updateInterval,
pauseManager: service.FromContext[pause.Manager](ctx),
}
}
@@ -85,13 +85,13 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext *adapter.
s.cacheFile = service.FromContext[adapter.CacheFile](s.ctx)
var dialer N.Dialer
if s.options.RemoteOptions.DownloadDetour != "" {
outbound, loaded := s.outboundManager.Outbound(s.options.RemoteOptions.DownloadDetour)
outbound, loaded := s.outbound.Outbound(s.options.RemoteOptions.DownloadDetour)
if !loaded {
return E.New("download_detour not found: ", s.options.RemoteOptions.DownloadDetour)
return E.New("download detour not found: ", s.options.RemoteOptions.DownloadDetour)
}
dialer = outbound
} else {
dialer = s.outboundManager.Default()
dialer = s.outbound.Default()
}
s.dialer = dialer
if s.cacheFile != nil {
@@ -292,7 +292,7 @@ func (s *RemoteRuleSet) fetchOnce(ctx context.Context, startContext *adapter.HTT
}
s.lastUpdated = time.Now()
if s.cacheFile != nil {
err = s.cacheFile.SaveRuleSet(s.options.Tag, &adapter.SavedRuleSet{
err = s.cacheFile.SaveRuleSet(s.options.Tag, &adapter.SavedBinary{
LastUpdated: s.lastUpdated,
Content: content,
LastEtag: s.lastEtag,

View File

@@ -1,6 +1,6 @@
include $(TOPDIR)/rules.mk
PKG_VERSION:=1.17.4
PKG_VERSION:=1.17.5
LUCI_TITLE:=LuCI Support for mihomo
LUCI_DEPENDS:=+luci-base +mihomo

View File

@@ -155,6 +155,16 @@ return view.extend({
o.retain = true;
o.depends('tun_gso', '1');
o = s.taboption('tun', form.Flag, 'tun_dns_hijack', '*' + ' ' + _('Overwrite DNS Hijack'));
o.rmempty = false;
o = s.taboption('tun', form.DynamicList, 'tun_dns_hijacks', '*' + ' ' + _('Edit DNS Hijacks'));
o.retain = true;
o.rmempty = false;
o.depends('tun_dns_hijack', '1');
o.value('tcp://any:53');
o.value('udp://any:53');
o = s.taboption('tun', form.Flag, 'tun_endpoint_independent_nat', '*' + ' ' + _('Endpoint Independent NAT'));
o.rmempty = false;

View File

@@ -26,7 +26,7 @@ msgstr ""
msgid "Allow Lan"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:190
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:198
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/proxy.js:65
msgid "Allow Mode"
msgstr ""
@@ -48,7 +48,7 @@ msgstr ""
msgid "Auto"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:189
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:197
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/proxy.js:66
msgid "Block Mode"
msgstr ""
@@ -111,15 +111,15 @@ msgstr ""
msgid "Cron Expression"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:161
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:169
msgid "DNS Config"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:167
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:175
msgid "DNS Mode"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:163
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:171
msgid "DNS Port"
msgstr ""
@@ -159,11 +159,11 @@ msgstr ""
msgid "Disable Safe Path Check"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:201
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:209
msgid "DoH Prefer HTTP/3"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:227
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:235
msgid "Domain Name"
msgstr ""
@@ -171,19 +171,23 @@ msgstr ""
msgid "Edit Authentications"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:183
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:161
msgid "Edit DNS Hijacks"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:191
msgid "Edit Fake-IP Filters"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:216
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:224
msgid "Edit Hosts"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:258
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:266
msgid "Edit Nameserver Policies"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:235
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:243
msgid "Edit Nameservers"
msgstr ""
@@ -200,17 +204,17 @@ msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:23
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:44
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:125
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:224
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:243
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:266
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:276
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:308
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:356
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:232
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:251
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:274
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:284
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:316
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:364
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/proxy.js:31
msgid "Enable"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:158
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:166
msgid "Endpoint Independent NAT"
msgstr ""
@@ -222,15 +226,15 @@ msgstr ""
msgid "External Control Config"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:193
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:201
msgid "Fake-IP Cache"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:187
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:195
msgid "Fake-IP Filter Mode"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:172
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:180
msgid "Fake-IP Range"
msgstr ""
@@ -255,7 +259,7 @@ msgstr ""
msgid "File:"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:291
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:299
msgid "Force Sniff Domain Name"
msgstr ""
@@ -271,39 +275,39 @@ msgstr ""
msgid "General Config"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:329
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:337
msgid "GeoData Loader"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:325
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:333
msgid "GeoIP Format"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:342
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:350
msgid "GeoIP(ASN) Url"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:339
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:347
msgid "GeoIP(DAT) Url"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:336
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:344
msgid "GeoIP(MMDB) Url"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:333
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:341
msgid "GeoSite Url"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:345
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:353
msgid "GeoX Auto Update"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:323
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:331
msgid "GeoX Config"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:348
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:356
msgid "GeoX Update Interval"
msgstr ""
@@ -323,7 +327,7 @@ msgstr ""
msgid "How To Use"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:230
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:238
msgid "IP"
msgstr ""
@@ -336,7 +340,7 @@ msgid "IPv4 Proxy"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:50
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:204
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:212
msgid "IPv6"
msgstr ""
@@ -348,7 +352,7 @@ msgstr ""
msgid "IPv6 Proxy"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:297
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:305
msgid "Ignore Sniff Domain Name"
msgstr ""
@@ -385,11 +389,11 @@ msgstr ""
msgid "Match Process"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:269
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:277
msgid "Matcher"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:331
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:339
msgid "Memory Conservative Loader"
msgstr ""
@@ -407,7 +411,7 @@ msgstr ""
msgid "Mixin Config"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:354
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:362
msgid "Mixin File Content"
msgstr ""
@@ -420,8 +424,8 @@ msgstr ""
msgid "Mode"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:253
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:272
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:261
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:280
msgid "Nameserver"
msgstr ""
@@ -441,36 +445,40 @@ msgstr ""
msgid "Overwrite Authentication"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:285
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:320
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:158
msgid "Overwrite DNS Hijack"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:293
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:328
msgid "Overwrite Destination"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:178
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:186
msgid "Overwrite Fake-IP Filter"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:288
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:296
msgid "Overwrite Force Sniff Domain Name"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:213
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:221
msgid "Overwrite Hosts"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:294
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:302
msgid "Overwrite Ignore Sniff Domain Name"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:232
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:240
msgid "Overwrite Nameserver"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:255
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:263
msgid "Overwrite Nameserver Policy"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:300
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:308
msgid "Overwrite Sniff By Protocol"
msgstr ""
@@ -478,11 +486,11 @@ msgstr ""
msgid "Password"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:356
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:364
msgid "Please go to the editor tab to edit the file for mixin"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:317
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:325
msgid "Port"
msgstr ""
@@ -499,7 +507,7 @@ msgstr ""
msgid "Profile for Startup"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:311
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:319
msgid "Protocol"
msgstr ""
@@ -524,7 +532,7 @@ msgstr ""
msgid "Remote"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:198
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:206
msgid "Respect Rules"
msgstr ""
@@ -561,24 +569,24 @@ msgstr ""
msgid "Scroll To Bottom"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/tools/mihomo.js:99
#: applications/luci-app-mihomo/htdocs/luci-static/resources/tools/mihomo.js:117
#: applications/luci-app-mihomo/htdocs/luci-static/resources/tools/mihomo.js:100
#: applications/luci-app-mihomo/htdocs/luci-static/resources/tools/mihomo.js:118
msgid "Service is not running."
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:303
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:311
msgid "Sniff By Protocol"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:282
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:290
msgid "Sniff Pure IP"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:279
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:287
msgid "Sniff Redir-Host"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:274
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:282
msgid "Sniffer Config"
msgstr ""
@@ -586,7 +594,7 @@ msgstr ""
msgid "Stack"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:330
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:338
msgid "Standard Loader"
msgstr ""
@@ -665,7 +673,7 @@ msgstr ""
msgid "Transparent Proxy with Mihomo on OpenWrt."
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:246
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:254
msgid "Type"
msgstr ""
@@ -706,11 +714,11 @@ msgstr ""
msgid "Upload Profile"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:210
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:218
msgid "Use Hosts"
msgstr ""
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:207
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:215
msgid "Use System Hosts"
msgstr ""

View File

@@ -33,7 +33,7 @@ msgstr "全部端口"
msgid "Allow Lan"
msgstr "允许局域网访问"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:190
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:198
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/proxy.js:65
msgid "Allow Mode"
msgstr "白名单模式"
@@ -55,7 +55,7 @@ msgstr "插件版本"
msgid "Auto"
msgstr "自动"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:189
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:197
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/proxy.js:66
msgid "Block Mode"
msgstr "黑名单模式"
@@ -118,15 +118,15 @@ msgstr "核心版本"
msgid "Cron Expression"
msgstr "Cron 表达式"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:161
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:169
msgid "DNS Config"
msgstr "DNS 配置"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:167
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:175
msgid "DNS Mode"
msgstr "DNS 模式"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:163
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:171
msgid "DNS Port"
msgstr "DNS 端口"
@@ -166,11 +166,11 @@ msgstr "禁用回环检测"
msgid "Disable Safe Path Check"
msgstr "禁用安全路径检查"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:201
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:209
msgid "DoH Prefer HTTP/3"
msgstr "DoH 优先 HTTP/3"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:227
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:235
msgid "Domain Name"
msgstr "域名"
@@ -178,19 +178,23 @@ msgstr "域名"
msgid "Edit Authentications"
msgstr "编辑身份验证"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:183
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:161
msgid "Edit DNS Hijacks"
msgstr "编辑 DNS 劫持"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:191
msgid "Edit Fake-IP Filters"
msgstr "编辑 Fake-IP 过滤列表"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:216
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:224
msgid "Edit Hosts"
msgstr "编辑 Hosts"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:258
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:266
msgid "Edit Nameserver Policies"
msgstr "编辑 DNS 服务器查询策略"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:235
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:243
msgid "Edit Nameservers"
msgstr "编辑 DNS 服务器"
@@ -207,17 +211,17 @@ msgstr "编辑器"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:23
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:44
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:125
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:224
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:243
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:266
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:276
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:308
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:356
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:232
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:251
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:274
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:284
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:316
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:364
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/proxy.js:31
msgid "Enable"
msgstr "启用"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:158
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:166
msgid "Endpoint Independent NAT"
msgstr "独立于端点的 NAT"
@@ -229,15 +233,15 @@ msgstr "到期时间"
msgid "External Control Config"
msgstr "外部控制配置"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:193
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:201
msgid "Fake-IP Cache"
msgstr "Fake-IP 缓存"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:187
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:195
msgid "Fake-IP Filter Mode"
msgstr "Fake-IP 过滤模式"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:172
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:180
msgid "Fake-IP Range"
msgstr "Fake-IP 范围"
@@ -262,7 +266,7 @@ msgstr "IPv6 保留地址"
msgid "File:"
msgstr "文件:"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:291
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:299
msgid "Force Sniff Domain Name"
msgstr "强制嗅探的域名"
@@ -278,39 +282,39 @@ msgstr "分段最大长度"
msgid "General Config"
msgstr "全局配置"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:329
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:337
msgid "GeoData Loader"
msgstr "GeoData 加载器"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:325
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:333
msgid "GeoIP Format"
msgstr "GeoIP 格式"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:342
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:350
msgid "GeoIP(ASN) Url"
msgstr "GeoIP(ASN) 下载地址"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:339
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:347
msgid "GeoIP(DAT) Url"
msgstr "GeoIP(DAT) 下载地址"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:336
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:344
msgid "GeoIP(MMDB) Url"
msgstr "GeoIP(MMDB) 下载地址"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:333
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:341
msgid "GeoSite Url"
msgstr "GeoSite 下载地址"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:345
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:353
msgid "GeoX Auto Update"
msgstr "定时更新GeoX文件"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:323
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:331
msgid "GeoX Config"
msgstr "GeoX 配置"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:348
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:356
msgid "GeoX Update Interval"
msgstr "GeoX 文件更新间隔"
@@ -330,7 +334,7 @@ msgstr "HTTP 端口"
msgid "How To Use"
msgstr "使用说明"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:230
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:238
msgid "IP"
msgstr ""
@@ -343,7 +347,7 @@ msgid "IPv4 Proxy"
msgstr "IPv4 代理"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:50
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:204
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:212
msgid "IPv6"
msgstr ""
@@ -355,7 +359,7 @@ msgstr "IPv6 DNS 劫持"
msgid "IPv6 Proxy"
msgstr "IPv6 代理"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:297
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:305
msgid "Ignore Sniff Domain Name"
msgstr "忽略嗅探的域名"
@@ -392,11 +396,11 @@ msgstr "最大传输单元"
msgid "Match Process"
msgstr "匹配进程"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:269
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:277
msgid "Matcher"
msgstr "匹配"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:331
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:339
msgid "Memory Conservative Loader"
msgstr "为内存受限设备优化的加载器"
@@ -414,7 +418,7 @@ msgstr "混合端口"
msgid "Mixin Config"
msgstr "混入配置"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:354
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:362
msgid "Mixin File Content"
msgstr "混入文件内容"
@@ -427,8 +431,8 @@ msgstr "混入选项"
msgid "Mode"
msgstr "模式"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:253
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:272
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:261
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:280
msgid "Nameserver"
msgstr "DNS 服务器"
@@ -448,36 +452,40 @@ msgstr "出站接口"
msgid "Overwrite Authentication"
msgstr "覆盖身份验证"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:285
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:320
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:158
msgid "Overwrite DNS Hijack"
msgstr "覆盖 DNS 劫持"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:293
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:328
msgid "Overwrite Destination"
msgstr "将嗅探结果作为连接目标"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:178
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:186
msgid "Overwrite Fake-IP Filter"
msgstr "覆盖 Fake-IP 过滤列表"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:288
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:296
msgid "Overwrite Force Sniff Domain Name"
msgstr "覆盖强制嗅探的域名"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:213
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:221
msgid "Overwrite Hosts"
msgstr "覆盖 Hosts"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:294
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:302
msgid "Overwrite Ignore Sniff Domain Name"
msgstr "覆盖忽略嗅探的域名"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:232
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:240
msgid "Overwrite Nameserver"
msgstr "覆盖 DNS 服务器"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:255
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:263
msgid "Overwrite Nameserver Policy"
msgstr "覆盖 DNS 服务器查询策略"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:300
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:308
msgid "Overwrite Sniff By Protocol"
msgstr "覆盖按协议嗅探"
@@ -485,11 +493,11 @@ msgstr "覆盖按协议嗅探"
msgid "Password"
msgstr "密码"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:356
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:364
msgid "Please go to the editor tab to edit the file for mixin"
msgstr "请前往编辑器标签编辑用于混入的文件"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:317
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:325
msgid "Port"
msgstr "端口"
@@ -506,7 +514,7 @@ msgstr "配置文件"
msgid "Profile for Startup"
msgstr "用于启动的配置文件"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:311
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:319
msgid "Protocol"
msgstr "协议"
@@ -531,7 +539,7 @@ msgstr "重载服务"
msgid "Remote"
msgstr "远程"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:198
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:206
msgid "Respect Rules"
msgstr "遵循分流规则"
@@ -568,24 +576,24 @@ msgstr "定时重启"
msgid "Scroll To Bottom"
msgstr "滚动到底部"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/tools/mihomo.js:99
#: applications/luci-app-mihomo/htdocs/luci-static/resources/tools/mihomo.js:117
#: applications/luci-app-mihomo/htdocs/luci-static/resources/tools/mihomo.js:100
#: applications/luci-app-mihomo/htdocs/luci-static/resources/tools/mihomo.js:118
msgid "Service is not running."
msgstr "服务未在运行。"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:303
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:311
msgid "Sniff By Protocol"
msgstr "按协议嗅探"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:282
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:290
msgid "Sniff Pure IP"
msgstr "嗅探纯 IP 连接"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:279
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:287
msgid "Sniff Redir-Host"
msgstr "嗅探 Redir-Host 流量"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:274
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:282
msgid "Sniffer Config"
msgstr "嗅探器配置"
@@ -593,7 +601,7 @@ msgstr "嗅探器配置"
msgid "Stack"
msgstr "栈"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:330
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:338
msgid "Standard Loader"
msgstr "标准加载器"
@@ -672,7 +680,7 @@ msgstr "透明代理"
msgid "Transparent Proxy with Mihomo on OpenWrt."
msgstr "在 OpenWrt 上使用 Mihomo 进行透明代理。"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:246
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:254
msgid "Type"
msgstr "类型"
@@ -713,11 +721,11 @@ msgstr "更新面板"
msgid "Upload Profile"
msgstr "上传配置文件"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:210
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:218
msgid "Use Hosts"
msgstr "使用 Hosts"
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:207
#: applications/luci-app-mihomo/htdocs/luci-static/resources/view/mihomo/mixin.js:215
msgid "Use System Hosts"
msgstr "使用系统的 Hosts"

Some files were not shown because too many files have changed in this diff Show More