refactor: 删除Tauri桌面模块,后续采用基于slint的原生应用
@@ -1,9 +0,0 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-ssr/
|
||||
*.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"tauri-apps.tauri-vscode",
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
# Orbien Desktop
|
||||
|
||||
Tauri 2 + Vue 3。 安装包内嵌 `orbien` sidecar
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
make desktop-dev # 开发
|
||||
make desktop-build # 打安装包 -> src-tauri/target/release/bundle/
|
||||
```
|
||||
|
||||
开发时用原生窗口,不要用浏览器打开 `localhost:1420`(无 Tauri IPC)
|
||||
|
||||
## 关键路径
|
||||
|
||||
| 路径 | 说明 |
|
||||
|-------------------------------|------------------------------------|
|
||||
| `src/` | 前端 |
|
||||
| `src-tauri/src/` | Rust / 进程与配置 |
|
||||
| `src-tauri/icons/` | **生效图标**(`icon.icns` / `icon.ico`) |
|
||||
| `src-tauri/binaries/orbien-*` | sidecar(构建生成) |
|
||||
| `src/assets/logo.png` | 侧栏 Logo |
|
||||
|
||||
## 换图标
|
||||
|
||||
有 `AppIcon.icns` 时:
|
||||
|
||||
```bash
|
||||
cd desktop
|
||||
TMP=$(mktemp -d)
|
||||
cp src/assets/AppIcon.icns "$TMP/icon.icns"
|
||||
(cd "$TMP" && iconutil -c iconset icon.icns)
|
||||
cp "$TMP/icon.iconset/icon_512x512@2x.png" src/assets/app-icon.png
|
||||
npx tauri icon src/assets/app-icon.png
|
||||
cp src/assets/AppIcon.icns src-tauri/icons/icon.icns
|
||||
```
|
||||
|
||||
只有 PNG:`npx tauri icon your-1024.png`
|
||||
|
||||
## macOS 下载(已损坏)
|
||||
|
||||
未公证时:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/Orbien Desktop.app"
|
||||
# 或
|
||||
./docs/scripts/macos-unquarantine.sh "/Applications/Orbien Desktop.app"
|
||||
```
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Orbien Desktop</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "orbien-desktop",
|
||||
"private": true,
|
||||
"version": "2.1.0-SNAPSHOT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.4.8",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 17 KiB |
@@ -1,3 +0,0 @@
|
||||
# Cargo / Tauri 生成物
|
||||
/target/
|
||||
/gen/schemas
|
||||
@@ -1,26 +0,0 @@
|
||||
[package]
|
||||
name = "orbien-desktop"
|
||||
version = "2.1.0-SNAPSHOT"
|
||||
description = "Orbien Desktop"
|
||||
authors = ["lxien"]
|
||||
edition = "2021"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
[lib]
|
||||
name = "orbien_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
@@ -1,5 +0,0 @@
|
||||
fn main() {
|
||||
let target = std::env::var("TARGET").unwrap_or_else(|_| "unknown".into());
|
||||
println!("cargo:rustc-env=ORBIEN_TARGET_TRIPLE={target}");
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 170 KiB |
@@ -1,661 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyTransportOptions {
|
||||
#[serde(default)]
|
||||
pub bandwidth_limit: String,
|
||||
#[serde(default = "default_bandwidth_mode")]
|
||||
pub bandwidth_limit_mode: String,
|
||||
#[serde(default)]
|
||||
pub proxy_protocol_version: String,
|
||||
}
|
||||
|
||||
fn default_bandwidth_mode() -> String {
|
||||
"client".into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyPluginConfig {
|
||||
#[serde(default, rename = "type", alias = "pluginType")]
|
||||
pub plugin_type: String,
|
||||
#[serde(default)]
|
||||
pub local_addr: String,
|
||||
#[serde(default)]
|
||||
pub crt_path: String,
|
||||
#[serde(default)]
|
||||
pub key_path: String,
|
||||
#[serde(default)]
|
||||
pub host_header_rewrite: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProxyConfig {
|
||||
pub name: String,
|
||||
|
||||
#[serde(default = "default_proxy_type")]
|
||||
pub proxy_type: String,
|
||||
#[serde(default = "default_local_ip")]
|
||||
pub local_ip: String,
|
||||
#[serde(default)]
|
||||
pub local_port: u16,
|
||||
#[serde(default)]
|
||||
pub remote_port: u16,
|
||||
|
||||
#[serde(default)]
|
||||
pub custom_domains: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub subdomain: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub locations: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub http_user: String,
|
||||
#[serde(default)]
|
||||
pub http_password: String,
|
||||
#[serde(default)]
|
||||
pub host_header_rewrite: String,
|
||||
#[serde(default)]
|
||||
pub route_by_http_user: String,
|
||||
#[serde(default)]
|
||||
pub transport: ProxyTransportOptions,
|
||||
#[serde(default)]
|
||||
pub plugin: Option<ProxyPluginConfig>,
|
||||
}
|
||||
|
||||
fn default_proxy_type() -> String {
|
||||
"tcp".into()
|
||||
}
|
||||
|
||||
fn default_local_ip() -> String {
|
||||
"127.0.0.1".into()
|
||||
}
|
||||
|
||||
fn trim_list(items: Vec<String>) -> Vec<String> {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
pub fn normalized(mut self) -> Self {
|
||||
self.name = self.name.trim().to_string();
|
||||
self.proxy_type = self.proxy_type.trim().to_lowercase();
|
||||
if self.proxy_type.is_empty() {
|
||||
self.proxy_type = default_proxy_type();
|
||||
}
|
||||
self.local_ip = self.local_ip.trim().to_string();
|
||||
if self.local_ip.is_empty() {
|
||||
self.local_ip = default_local_ip();
|
||||
}
|
||||
self.custom_domains = trim_list(self.custom_domains);
|
||||
self.locations = trim_list(self.locations);
|
||||
self.subdomain = self.subdomain.trim().to_string();
|
||||
self.http_user = self.http_user.trim().to_string();
|
||||
self.http_password = self.http_password.trim().to_string();
|
||||
self.host_header_rewrite = self.host_header_rewrite.trim().to_string();
|
||||
self.route_by_http_user = self.route_by_http_user.trim().to_string();
|
||||
|
||||
self.transport.bandwidth_limit = self.transport.bandwidth_limit.trim().to_string();
|
||||
self.transport.bandwidth_limit_mode =
|
||||
self.transport.bandwidth_limit_mode.trim().to_lowercase();
|
||||
if self.transport.bandwidth_limit_mode.is_empty() {
|
||||
self.transport.bandwidth_limit_mode = default_bandwidth_mode();
|
||||
}
|
||||
self.transport.proxy_protocol_version =
|
||||
self.transport.proxy_protocol_version.trim().to_lowercase();
|
||||
|
||||
if let Some(mut pl) = self.plugin.take() {
|
||||
pl.plugin_type = pl.plugin_type.trim().to_lowercase();
|
||||
pl.local_addr = pl.local_addr.trim().to_string();
|
||||
pl.crt_path = pl.crt_path.trim().to_string();
|
||||
pl.key_path = pl.key_path.trim().to_string();
|
||||
pl.host_header_rewrite = pl.host_header_rewrite.trim().to_string();
|
||||
if pl.plugin_type.is_empty() {
|
||||
self.plugin = None;
|
||||
} else {
|
||||
self.plugin = Some(pl);
|
||||
}
|
||||
}
|
||||
|
||||
match self.proxy_type.as_str() {
|
||||
"tcp" | "udp" => {
|
||||
self.custom_domains.clear();
|
||||
self.subdomain.clear();
|
||||
self.locations.clear();
|
||||
self.http_user.clear();
|
||||
self.http_password.clear();
|
||||
self.host_header_rewrite.clear();
|
||||
self.route_by_http_user.clear();
|
||||
self.plugin = None;
|
||||
if self.remote_port == 0 {
|
||||
self.remote_port = 1;
|
||||
}
|
||||
if self.local_port == 0 {
|
||||
self.local_port = 1;
|
||||
}
|
||||
}
|
||||
"http" => {
|
||||
self.remote_port = 0;
|
||||
self.plugin = None;
|
||||
self.http_user.clear();
|
||||
self.http_password.clear();
|
||||
self.route_by_http_user.clear();
|
||||
if self.local_port == 0 {
|
||||
self.local_port = 80;
|
||||
}
|
||||
}
|
||||
"https" => {
|
||||
self.remote_port = 0;
|
||||
self.locations.clear();
|
||||
self.http_user.clear();
|
||||
self.http_password.clear();
|
||||
self.route_by_http_user.clear();
|
||||
|
||||
if self.plugin.is_some() {
|
||||
self.local_port = 0;
|
||||
self.host_header_rewrite.clear();
|
||||
} else {
|
||||
self.host_header_rewrite.clear();
|
||||
if self.local_port == 0 {
|
||||
self.local_port = 443;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if self.uses_plugin() {
|
||||
self.transport.proxy_protocol_version.clear();
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
pub fn uses_plugin(&self) -> bool {
|
||||
self.plugin
|
||||
.as_ref()
|
||||
.map(|p| !p.plugin_type.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn local_label(&self) -> String {
|
||||
if let Some(pl) = &self.plugin {
|
||||
if !pl.plugin_type.is_empty() {
|
||||
if pl.local_addr.is_empty() {
|
||||
return format!("plugin:{}", pl.plugin_type);
|
||||
}
|
||||
return pl.local_addr.clone();
|
||||
}
|
||||
}
|
||||
match self.proxy_type.as_str() {
|
||||
"tcp" | "udp" | "http" | "https" => self.local_port.to_string(),
|
||||
_ => format!("{}:{}", self.local_ip, self.local_port),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remote_hosts(&self) -> Vec<String> {
|
||||
let mut hosts = self.custom_domains.clone();
|
||||
let sub = self.subdomain.trim();
|
||||
if !sub.is_empty() {
|
||||
hosts.push(sub.to_string());
|
||||
}
|
||||
hosts
|
||||
}
|
||||
|
||||
pub fn remote_label(&self) -> String {
|
||||
match self.proxy_type.as_str() {
|
||||
"http" | "https" => {
|
||||
let hosts = self.remote_hosts();
|
||||
if hosts.is_empty() {
|
||||
"—".into()
|
||||
} else {
|
||||
hosts.join(", ")
|
||||
}
|
||||
}
|
||||
"tcp" | "udp" => self.remote_port.to_string(),
|
||||
_ => format!(":{}", self.remote_port),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_address(&self, server_addr: &str) -> String {
|
||||
let host = server_addr.trim();
|
||||
match self.proxy_type.as_str() {
|
||||
"tcp" | "udp" => {
|
||||
if host.is_empty() {
|
||||
self.remote_port.to_string()
|
||||
} else if host.contains(':') && !host.starts_with('[') {
|
||||
format!("[{host}]:{}", self.remote_port)
|
||||
} else {
|
||||
format!("{host}:{}", self.remote_port)
|
||||
}
|
||||
}
|
||||
"http" | "https" => {
|
||||
let scheme = if self.proxy_type == "https" {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
if self.custom_domains.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
self.custom_domains
|
||||
.iter()
|
||||
.map(|h| format!("{scheme}://{h}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_toml_fragment(&self) -> String {
|
||||
let mut out = format!(
|
||||
"\n[[proxies]]\nname = \"{name}\"\ntype = \"{ty}\"\n",
|
||||
name = escape_toml(&self.name),
|
||||
ty = escape_toml(&self.proxy_type),
|
||||
);
|
||||
|
||||
let plugin_active = self.uses_plugin();
|
||||
if !plugin_active {
|
||||
out.push_str(&format!(
|
||||
"localIP = \"{lip}\"\nlocalPort = {lport}\n",
|
||||
lip = escape_toml(&self.local_ip),
|
||||
lport = self.local_port,
|
||||
));
|
||||
}
|
||||
|
||||
match self.proxy_type.as_str() {
|
||||
"tcp" | "udp" => {
|
||||
out.push_str(&format!("remotePort = {}\n", self.remote_port));
|
||||
}
|
||||
"http" | "https" => {
|
||||
if !self.custom_domains.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"customDomains = {}\n",
|
||||
toml_string_array(&self.custom_domains)
|
||||
));
|
||||
}
|
||||
if !self.subdomain.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"subdomain = \"{}\"\n",
|
||||
escape_toml(&self.subdomain)
|
||||
));
|
||||
}
|
||||
if self.proxy_type == "http" {
|
||||
if !self.locations.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"locations = {}\n",
|
||||
toml_string_array(&self.locations)
|
||||
));
|
||||
}
|
||||
|
||||
if !self.host_header_rewrite.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"hostHeaderRewrite = \"{}\"\n",
|
||||
escape_toml(&self.host_header_rewrite)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if !self.transport.bandwidth_limit.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"transport.bandwidthLimit = \"{}\"\n",
|
||||
escape_toml(&self.transport.bandwidth_limit)
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"transport.bandwidthLimitMode = \"{}\"\n",
|
||||
escape_toml(&self.transport.bandwidth_limit_mode)
|
||||
));
|
||||
}
|
||||
if !self.transport.proxy_protocol_version.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"transport.proxyProtocolVersion = \"{}\"\n",
|
||||
escape_toml(&self.transport.proxy_protocol_version)
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(pl) = &self.plugin {
|
||||
if !pl.plugin_type.is_empty() {
|
||||
out.push_str(&format!(
|
||||
"\n[proxies.plugin]\ntype = \"{ty}\"\nlocalAddr = \"{addr}\"\ncrtPath = \"{crt}\"\nkeyPath = \"{key}\"\nhostHeaderRewrite = \"{hh}\"\n",
|
||||
ty = escape_toml(&pl.plugin_type),
|
||||
addr = escape_toml(&pl.local_addr),
|
||||
crt = escape_toml(&pl.crt_path),
|
||||
key = escape_toml(&pl.key_path),
|
||||
hh = escape_toml(&pl.host_header_rewrite),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn toml_string_array(items: &[String]) -> String {
|
||||
let inner = items
|
||||
.iter()
|
||||
.map(|s| format!("\"{}\"", escape_toml(s)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!("[{inner}]")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TlsConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub enable: bool,
|
||||
#[serde(default)]
|
||||
pub cert_file: String,
|
||||
#[serde(default)]
|
||||
pub key_file: String,
|
||||
#[serde(default)]
|
||||
pub trusted_ca_file: String,
|
||||
#[serde(default)]
|
||||
pub server_name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub disable_custom_tls_first_byte: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for TlsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enable: true,
|
||||
cert_file: String::new(),
|
||||
key_file: String::new(),
|
||||
trusted_ca_file: String::new(),
|
||||
server_name: String::new(),
|
||||
disable_custom_tls_first_byte: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TlsConfig {
|
||||
fn normalized(mut self) -> Self {
|
||||
self.cert_file = self.cert_file.trim().to_string();
|
||||
self.key_file = self.key_file.trim().to_string();
|
||||
self.trusted_ca_file = self.trusted_ca_file.trim().to_string();
|
||||
self.server_name = self.server_name.trim().to_string();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuicConfig {
|
||||
#[serde(default = "default_quic_keepalive")]
|
||||
pub keepalive_period: u64,
|
||||
#[serde(default = "default_quic_idle")]
|
||||
pub max_idle_timeout: u64,
|
||||
#[serde(default = "default_quic_streams")]
|
||||
pub max_incoming_streams: u32,
|
||||
}
|
||||
|
||||
fn default_quic_keepalive() -> u64 {
|
||||
10
|
||||
}
|
||||
fn default_quic_idle() -> u64 {
|
||||
30
|
||||
}
|
||||
fn default_quic_streams() -> u32 {
|
||||
100_000
|
||||
}
|
||||
|
||||
impl Default for QuicConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keepalive_period: default_quic_keepalive(),
|
||||
max_idle_timeout: default_quic_idle(),
|
||||
max_incoming_streams: default_quic_streams(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ClientConfig {
|
||||
pub server_addr: String,
|
||||
pub server_port: u16,
|
||||
|
||||
#[serde(default)]
|
||||
pub user: String,
|
||||
pub token: String,
|
||||
|
||||
#[serde(default = "default_udp_packet_size")]
|
||||
pub udp_packet_size: u32,
|
||||
pub protocol: String,
|
||||
#[serde(default = "default_pool_count")]
|
||||
pub pool_count: i32,
|
||||
pub tcp_mux: bool,
|
||||
#[serde(default = "default_tcp_mux_keepalive")]
|
||||
pub tcp_mux_keepalive_interval: i64,
|
||||
#[serde(default = "default_heartbeat_interval")]
|
||||
pub heartbeat_interval: i64,
|
||||
#[serde(default = "default_heartbeat_timeout")]
|
||||
pub heartbeat_timeout: i64,
|
||||
#[serde(default)]
|
||||
pub tls: TlsConfig,
|
||||
#[serde(default)]
|
||||
pub quic: QuicConfig,
|
||||
|
||||
pub orbien_path: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub proxies: Vec<ProxyConfig>,
|
||||
}
|
||||
|
||||
fn default_udp_packet_size() -> u32 {
|
||||
1500
|
||||
}
|
||||
fn default_pool_count() -> i32 {
|
||||
1
|
||||
}
|
||||
fn default_tcp_mux_keepalive() -> i64 {
|
||||
30
|
||||
}
|
||||
fn default_heartbeat_interval() -> i64 {
|
||||
-1
|
||||
}
|
||||
fn default_heartbeat_timeout() -> i64 {
|
||||
-1
|
||||
}
|
||||
|
||||
impl ClientConfig {
|
||||
pub fn normalized(mut self) -> Self {
|
||||
self.server_addr = self.server_addr.trim().to_string();
|
||||
self.user = self.user.trim().to_string();
|
||||
self.token = self.token.trim().to_string();
|
||||
self.protocol = self.protocol.trim().to_lowercase();
|
||||
if self.protocol.is_empty() {
|
||||
self.protocol = "tcp".into();
|
||||
}
|
||||
if self.protocol == "ws" {
|
||||
self.protocol = "websocket".into();
|
||||
}
|
||||
self.orbien_path.clear();
|
||||
if self.server_port == 0 {
|
||||
self.server_port = 9527;
|
||||
}
|
||||
if self.udp_packet_size == 0 {
|
||||
self.udp_packet_size = 1500;
|
||||
}
|
||||
if self.pool_count < 0 {
|
||||
self.pool_count = 1;
|
||||
}
|
||||
if self.tcp_mux_keepalive_interval <= 0 {
|
||||
self.tcp_mux_keepalive_interval = 30;
|
||||
}
|
||||
|
||||
if !self.tcp_mux {
|
||||
if self.heartbeat_interval < 0 {
|
||||
self.heartbeat_interval = 30;
|
||||
}
|
||||
if self.heartbeat_timeout < 0 {
|
||||
self.heartbeat_timeout = 90;
|
||||
}
|
||||
}
|
||||
if self.quic.keepalive_period == 0 {
|
||||
self.quic.keepalive_period = 10;
|
||||
}
|
||||
if self.quic.max_idle_timeout == 0 {
|
||||
self.quic.max_idle_timeout = 30;
|
||||
}
|
||||
if self.quic.max_incoming_streams == 0 {
|
||||
self.quic.max_incoming_streams = 100_000;
|
||||
}
|
||||
self.tls = self.tls.normalized();
|
||||
self.proxies = self
|
||||
.proxies
|
||||
.into_iter()
|
||||
.map(ProxyConfig::normalized)
|
||||
.filter(|p| !p.name.is_empty())
|
||||
.collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn to_orbien_toml(&self) -> String {
|
||||
let mut out = format!(
|
||||
r#"# Generated by Orbien Desktop — do not edit while app is running
|
||||
serverAddr = "{addr}"
|
||||
serverPort = {port}
|
||||
user = "{user}"
|
||||
udpPacketSize = {udp}
|
||||
|
||||
[auth]
|
||||
method = "token"
|
||||
token = "{token}"
|
||||
|
||||
[transport]
|
||||
protocol = "{proto}"
|
||||
tcpMux = {mux}
|
||||
tcpMuxKeepaliveInterval = {mux_ka}
|
||||
poolCount = {pool}
|
||||
heartbeatInterval = {hb_i}
|
||||
heartbeatTimeout = {hb_t}
|
||||
|
||||
[transport.tls]
|
||||
enable = {tls_en}
|
||||
certFile = "{tls_cert}"
|
||||
keyFile = "{tls_key}"
|
||||
trustedCaFile = "{tls_ca}"
|
||||
serverName = "{tls_sni}"
|
||||
disableCustomTLSFirstByte = {tls_first}
|
||||
|
||||
[transport.quic]
|
||||
keepalivePeriod = {q_ka}
|
||||
maxIdleTimeout = {q_idle}
|
||||
maxIncomingStreams = {q_streams}
|
||||
"#,
|
||||
addr = escape_toml(&self.server_addr),
|
||||
port = self.server_port,
|
||||
user = escape_toml(&self.user),
|
||||
udp = self.udp_packet_size,
|
||||
token = escape_toml(&self.token),
|
||||
proto = escape_toml(&self.protocol),
|
||||
mux = self.tcp_mux,
|
||||
mux_ka = self.tcp_mux_keepalive_interval,
|
||||
pool = self.pool_count,
|
||||
hb_i = self.heartbeat_interval,
|
||||
hb_t = self.heartbeat_timeout,
|
||||
tls_en = self.tls.enable,
|
||||
tls_cert = escape_toml(&self.tls.cert_file),
|
||||
tls_key = escape_toml(&self.tls.key_file),
|
||||
tls_ca = escape_toml(&self.tls.trusted_ca_file),
|
||||
tls_sni = escape_toml(&self.tls.server_name),
|
||||
tls_first = self.tls.disable_custom_tls_first_byte,
|
||||
q_ka = self.quic.keepalive_period,
|
||||
q_idle = self.quic.max_idle_timeout,
|
||||
q_streams = self.quic.max_incoming_streams,
|
||||
);
|
||||
|
||||
for p in &self.proxies {
|
||||
out.push_str(&p.to_toml_fragment());
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_toml(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
pub fn default_config() -> ClientConfig {
|
||||
ClientConfig {
|
||||
server_addr: "127.0.0.1".into(),
|
||||
server_port: 9527,
|
||||
user: String::new(),
|
||||
token: String::new(),
|
||||
udp_packet_size: 1500,
|
||||
protocol: "tcp".into(),
|
||||
pool_count: 1,
|
||||
tcp_mux: true,
|
||||
tcp_mux_keepalive_interval: 30,
|
||||
heartbeat_interval: -1,
|
||||
heartbeat_timeout: -1,
|
||||
tls: TlsConfig::default(),
|
||||
quic: QuicConfig::default(),
|
||||
orbien_path: String::new(),
|
||||
proxies: Vec::new(),
|
||||
}
|
||||
.normalized()
|
||||
}
|
||||
|
||||
fn config_file(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| format!("app config dir: {e}"))?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("mkdir config: {e}"))?;
|
||||
Ok(dir.join("client.json"))
|
||||
}
|
||||
|
||||
fn runtime_toml_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| format!("app config dir: {e}"))?;
|
||||
fs::create_dir_all(&dir).map_err(|e| format!("mkdir config: {e}"))?;
|
||||
Ok(dir.join("orbien.runtime.toml"))
|
||||
}
|
||||
|
||||
pub fn load_config(app: &AppHandle) -> Result<ClientConfig, String> {
|
||||
let path = config_file(app)?;
|
||||
if !path.exists() {
|
||||
let cfg = default_config();
|
||||
save_config(app, &cfg)?;
|
||||
return Ok(cfg);
|
||||
}
|
||||
let raw = fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?;
|
||||
let cfg: ClientConfig =
|
||||
serde_json::from_str(&raw).map_err(|e| format!("parse {}: {e}", path.display()))?;
|
||||
Ok(cfg.normalized())
|
||||
}
|
||||
|
||||
pub fn save_config(app: &AppHandle, cfg: &ClientConfig) -> Result<(), String> {
|
||||
let path = config_file(app)?;
|
||||
let raw = serde_json::to_string_pretty(cfg).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, raw).map_err(|e| format!("write {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
pub fn write_runtime_toml(app: &AppHandle, cfg: &ClientConfig) -> Result<PathBuf, String> {
|
||||
let path = runtime_toml_path(app)?;
|
||||
fs::write(&path, cfg.to_orbien_toml()).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -1,469 +0,0 @@
|
||||
mod config;
|
||||
mod pick_file;
|
||||
mod process;
|
||||
|
||||
use config::{
|
||||
default_config, load_config, save_config, write_runtime_toml, ClientConfig, ProxyConfig,
|
||||
};
|
||||
use process::{resolve_orbien, spawn_orbien, stop_child};
|
||||
use serde::Serialize;
|
||||
use std::collections::VecDeque;
|
||||
use std::process::Child;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
const RESTART_GAP: Duration = Duration::from_millis(400);
|
||||
const MAX_LOG_LINES: usize = 800;
|
||||
const MAX_LOG_LINE_CHARS: usize = 4_096;
|
||||
|
||||
pub(crate) struct SessionInner {
|
||||
running: bool,
|
||||
started_at: Option<Instant>,
|
||||
logs: VecDeque<String>,
|
||||
logs_rev: u64,
|
||||
child: Option<Child>,
|
||||
config: ClientConfig,
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
inner: Arc<Mutex<SessionInner>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new(config: ClientConfig) -> Self {
|
||||
let mut session = SessionInner {
|
||||
running: false,
|
||||
started_at: None,
|
||||
logs: VecDeque::new(),
|
||||
logs_rev: 0,
|
||||
child: None,
|
||||
config,
|
||||
};
|
||||
push_log(
|
||||
&mut session,
|
||||
format!("[info] Orbien Desktop {APP_VERSION} ready"),
|
||||
);
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(session)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ClientStatus {
|
||||
running: bool,
|
||||
running_secs: u64,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RuntimeStats {
|
||||
cpu_percent: f64,
|
||||
memory_mb: u64,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxyItem {
|
||||
name: String,
|
||||
proxy_type: String,
|
||||
local: String,
|
||||
remote: String,
|
||||
copy_value: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SaveProxiesResult {
|
||||
proxies: Vec<ProxyItem>,
|
||||
restarted: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SaveConfigResult {
|
||||
config: ClientConfig,
|
||||
restarted: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LogsSnapshot {
|
||||
rev: u64,
|
||||
lines: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn push_log(session: &mut SessionInner, line: impl Into<String>) {
|
||||
let mut line = strip_ansi(&line.into());
|
||||
if line.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if line.len() > MAX_LOG_LINE_CHARS {
|
||||
let mut end = MAX_LOG_LINE_CHARS;
|
||||
while end > 0 && !line.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
line.truncate(end);
|
||||
line.push('…');
|
||||
}
|
||||
session.logs.push_back(line);
|
||||
while session.logs.len() > MAX_LOG_LINES {
|
||||
session.logs.pop_front();
|
||||
}
|
||||
session.logs_rev = session.logs_rev.wrapping_add(1);
|
||||
}
|
||||
|
||||
fn strip_ansi(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut chars = input.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '\u{1b}' {
|
||||
if chars.peek() == Some(&'[') {
|
||||
chars.next();
|
||||
for ch in chars.by_ref() {
|
||||
if ch.is_ascii_alphabetic() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if c.is_control() && c != '\t' {
|
||||
continue;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn status_from(session: &SessionInner) -> ClientStatus {
|
||||
ClientStatus {
|
||||
running: session.running,
|
||||
running_secs: session
|
||||
.started_at
|
||||
.map(|t| t.elapsed().as_secs())
|
||||
.unwrap_or(0),
|
||||
version: APP_VERSION.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn proxy_items(cfg: &ClientConfig) -> Vec<ProxyItem> {
|
||||
cfg.proxies
|
||||
.iter()
|
||||
.map(|p| ProxyItem {
|
||||
name: p.name.clone(),
|
||||
proxy_type: p.proxy_type.clone(),
|
||||
local: p.local_label(),
|
||||
remote: p.remote_label(),
|
||||
copy_value: p.copy_address(&cfg.server_addr),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn reap_if_exited(session: &mut SessionInner) {
|
||||
if !session.running {
|
||||
return;
|
||||
}
|
||||
let Some(child) = session.child.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let secs = session
|
||||
.started_at
|
||||
.map(|t| t.elapsed())
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_secs();
|
||||
session.child = None;
|
||||
session.running = false;
|
||||
session.started_at = None;
|
||||
push_log(
|
||||
session,
|
||||
format!("[warn] orbien exited after {secs}s ({status})"),
|
||||
);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
push_log(session, format!("[error] wait orbien failed: {e}"));
|
||||
session.child = None;
|
||||
session.running = false;
|
||||
session.started_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_session(session: &mut SessionInner, reason: &str) {
|
||||
let secs = session
|
||||
.started_at
|
||||
.map(|t| t.elapsed())
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_secs();
|
||||
|
||||
if let Some(child) = session.child.take() {
|
||||
match stop_child(child) {
|
||||
Ok(()) => push_log(
|
||||
session,
|
||||
format!("[info] orbien stopped after {secs}s ({reason})"),
|
||||
),
|
||||
Err(e) => push_log(session, format!("[error] stop orbien: {e}")),
|
||||
}
|
||||
} else if session.running {
|
||||
push_log(session, "[info] cleared stale running flag");
|
||||
}
|
||||
|
||||
session.running = false;
|
||||
session.started_at = None;
|
||||
}
|
||||
|
||||
fn start_session(
|
||||
app: &AppHandle,
|
||||
logs: Arc<Mutex<SessionInner>>,
|
||||
session: &mut SessionInner,
|
||||
) -> Result<(), String> {
|
||||
let cfg = session.config.clone();
|
||||
let toml_path = match write_runtime_toml(app, &cfg) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
push_log(session, format!("[error] write runtime config failed: {e}"));
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let bin = match resolve_orbien(&cfg.orbien_path) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
push_log(session, format!("[error] {e}"));
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
push_log(
|
||||
session,
|
||||
format!(
|
||||
"[info] starting {} -c {}",
|
||||
bin.display(),
|
||||
toml_path.display()
|
||||
),
|
||||
);
|
||||
|
||||
let child = match spawn_orbien(&bin, &toml_path, logs) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
push_log(session, format!("[error] {e}"));
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
session.child = Some(child);
|
||||
session.running = true;
|
||||
session.started_at = Some(Instant::now());
|
||||
push_log(
|
||||
session,
|
||||
format!(
|
||||
"[info] orbien started → {}:{} ({}) with {} proxy(ies)",
|
||||
cfg.server_addr,
|
||||
cfg.server_port,
|
||||
cfg.protocol,
|
||||
cfg.proxies.len()
|
||||
),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restart_session(
|
||||
app: &AppHandle,
|
||||
logs: Arc<Mutex<SessionInner>>,
|
||||
session: &mut SessionInner,
|
||||
reason: &str,
|
||||
) -> Result<(), String> {
|
||||
push_log(
|
||||
session,
|
||||
format!("[info] applying changes via restart ({reason})"),
|
||||
);
|
||||
stop_session(session, reason);
|
||||
|
||||
thread::sleep(RESTART_GAP);
|
||||
start_session(app, logs, session)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_status(state: State<'_, AppState>) -> ClientStatus {
|
||||
let mut s = state.inner.lock().expect("session lock");
|
||||
reap_if_exited(&mut s);
|
||||
status_from(&s)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_config(state: State<'_, AppState>) -> ClientConfig {
|
||||
state.inner.lock().expect("session lock").config.clone()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_client_config(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
config: ClientConfig,
|
||||
) -> Result<SaveConfigResult, String> {
|
||||
let cfg = config.normalized();
|
||||
save_config(&app, &cfg)?;
|
||||
|
||||
let mut s = state.inner.lock().map_err(|e| e.to_string())?;
|
||||
reap_if_exited(&mut s);
|
||||
let was_running = s.running;
|
||||
s.config = cfg.clone();
|
||||
push_log(&mut s, "[info] config saved");
|
||||
|
||||
let restarted = if was_running {
|
||||
restart_session(&app, Arc::clone(&state.inner), &mut s, "config updated")?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(SaveConfigResult {
|
||||
config: cfg,
|
||||
restarted,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_client(app: AppHandle, state: State<'_, AppState>) -> Result<ClientStatus, String> {
|
||||
let mut s = state.inner.lock().map_err(|e| e.to_string())?;
|
||||
reap_if_exited(&mut s);
|
||||
if s.running {
|
||||
return Err("client already running".into());
|
||||
}
|
||||
start_session(&app, Arc::clone(&state.inner), &mut s)?;
|
||||
Ok(status_from(&s))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_client(state: State<'_, AppState>) -> Result<ClientStatus, String> {
|
||||
let mut s = state.inner.lock().map_err(|e| e.to_string())?;
|
||||
if !s.running && s.child.is_none() {
|
||||
return Err("client is not running".into());
|
||||
}
|
||||
stop_session(&mut s, "user stop");
|
||||
Ok(status_from(&s))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_logs(state: State<'_, AppState>, since_rev: u64) -> LogsSnapshot {
|
||||
let s = state.inner.lock().expect("session lock");
|
||||
if since_rev == s.logs_rev && since_rev != 0 {
|
||||
return LogsSnapshot {
|
||||
rev: s.logs_rev,
|
||||
lines: None,
|
||||
};
|
||||
}
|
||||
LogsSnapshot {
|
||||
rev: s.logs_rev,
|
||||
lines: Some(s.logs.iter().cloned().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_logs(state: State<'_, AppState>) {
|
||||
let mut s = state.inner.lock().expect("session lock");
|
||||
s.logs.clear();
|
||||
push_log(&mut s, "[info] logs cleared");
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_runtime_stats() -> RuntimeStats {
|
||||
RuntimeStats {
|
||||
cpu_percent: 0.0,
|
||||
memory_mb: 0,
|
||||
version: APP_VERSION.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_proxies(state: State<'_, AppState>) -> Vec<ProxyItem> {
|
||||
let cfg = state.inner.lock().expect("session lock").config.clone();
|
||||
proxy_items(&cfg)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_proxies(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
proxies: Vec<ProxyConfig>,
|
||||
) -> Result<SaveProxiesResult, String> {
|
||||
let next: Vec<ProxyConfig> = proxies
|
||||
.into_iter()
|
||||
.map(ProxyConfig::normalized)
|
||||
.filter(|p| !p.name.is_empty())
|
||||
.collect();
|
||||
|
||||
let mut s = state.inner.lock().map_err(|e| e.to_string())?;
|
||||
reap_if_exited(&mut s);
|
||||
|
||||
let changed = s.config.proxies != next;
|
||||
let was_running = s.running;
|
||||
s.config.proxies = next;
|
||||
save_config(&app, &s.config)?;
|
||||
let proxy_count = s.config.proxies.len();
|
||||
push_log(
|
||||
&mut s,
|
||||
format!(
|
||||
"[info] proxies saved ({} item(s){})",
|
||||
proxy_count,
|
||||
if changed { ", changed" } else { ", unchanged" }
|
||||
),
|
||||
);
|
||||
|
||||
let restarted = if was_running && changed {
|
||||
restart_session(&app, Arc::clone(&state.inner), &mut s, "proxies updated")?;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Ok(SaveProxiesResult {
|
||||
proxies: proxy_items(&s.config),
|
||||
restarted,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn append_log_line(state: &Arc<Mutex<SessionInner>>, line: String) {
|
||||
if let Ok(mut s) = state.lock() {
|
||||
push_log(&mut s, line);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
let cfg = load_config(app.handle()).unwrap_or_else(|e| {
|
||||
eprintln!("load config failed: {e}; using defaults");
|
||||
default_config()
|
||||
});
|
||||
app.manage(AppState::new(cfg));
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_status,
|
||||
get_config,
|
||||
save_client_config,
|
||||
start_client,
|
||||
stop_client,
|
||||
get_logs,
|
||||
clear_logs,
|
||||
get_runtime_stats,
|
||||
list_proxies,
|
||||
save_proxies,
|
||||
pick_file::pick_file,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Orbien Desktop");
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
orbien_desktop_lib::run()
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileFilter {
|
||||
#[allow(dead_code)]
|
||||
pub name: String,
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn pick_file(
|
||||
title: Option<String>,
|
||||
filters: Option<Vec<FileFilter>>,
|
||||
) -> Result<Option<String>, String> {
|
||||
let title = title.unwrap_or_else(|| "Select file".into());
|
||||
let filters = filters.unwrap_or_default();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
return pick_macos(&title, &filters);
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
return pick_windows(&title, &filters);
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
return pick_linux(&title, &filters);
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
let _ = (title, filters);
|
||||
Err("file picker is not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn pick_macos(title: &str, filters: &[FileFilter]) -> Result<Option<String>, String> {
|
||||
let mut script = format!(
|
||||
"try\nset theFile to choose file with prompt \"{}\"",
|
||||
escape_applescript(title)
|
||||
);
|
||||
let exts: Vec<String> = filters
|
||||
.iter()
|
||||
.flat_map(|f| f.extensions.iter().cloned())
|
||||
.filter(|e| !e.is_empty())
|
||||
.collect();
|
||||
if !exts.is_empty() {
|
||||
let _ = exts;
|
||||
}
|
||||
script.push_str("\nPOSIX path of theFile\non error number -128\nreturn \"\"\nend try");
|
||||
|
||||
let out = Command::new("osascript")
|
||||
.args(["-e", &script])
|
||||
.output()
|
||||
.map_err(|e| format!("osascript: {e}"))?;
|
||||
if !out.status.success() {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
if err.contains("-128") || out.stdout.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(format!("osascript failed: {err}"));
|
||||
}
|
||||
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn escape_applescript(s: &str) -> String {
|
||||
s.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn pick_windows(title: &str, filters: &[FileFilter]) -> Result<Option<String>, String> {
|
||||
let mut filter = String::new();
|
||||
for f in filters {
|
||||
if f.extensions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let patterns = f
|
||||
.extensions
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let e = e.trim_start_matches('.');
|
||||
if e == "*" {
|
||||
"*.*".into()
|
||||
} else {
|
||||
format!("*.{e}")
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
if !filter.is_empty() {
|
||||
filter.push('|');
|
||||
}
|
||||
filter.push_str(&format!("{} ({})|{}", f.name, patterns, patterns));
|
||||
}
|
||||
if filter.is_empty() {
|
||||
filter = "All files (*.*)|*.*".into();
|
||||
} else if !filter.to_ascii_lowercase().contains("*.*") {
|
||||
filter.push_str("|All files (*.*)|*.*");
|
||||
}
|
||||
|
||||
let ps = format!(
|
||||
r#"Add-Type -AssemblyName System.Windows.Forms; $d = New-Object System.Windows.Forms.OpenFileDialog; $d.Title = '{title}'; $d.Filter = '{filter}'; $d.Multiselect = $false; if ($d.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {{ $d.FileName }} else {{ '' }}"#,
|
||||
title = escape_ps(title),
|
||||
filter = escape_ps(&filter),
|
||||
);
|
||||
|
||||
let out = Command::new("powershell")
|
||||
.args(["-NoProfile", "-Command", &ps])
|
||||
.output()
|
||||
.map_err(|e| format!("powershell: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"powershell failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
));
|
||||
}
|
||||
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn escape_ps(s: &str) -> String {
|
||||
s.replace('\'', "''")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn pick_linux(title: &str, filters: &[FileFilter]) -> Result<Option<String>, String> {
|
||||
if let Ok(path) = try_zenity(title, filters) {
|
||||
return Ok(path);
|
||||
}
|
||||
if let Ok(path) = try_kdialog(title, filters) {
|
||||
return Ok(path);
|
||||
}
|
||||
Err("no file dialog available (install zenity or kdialog)".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn try_zenity(title: &str, filters: &[FileFilter]) -> Result<Option<String>, String> {
|
||||
let mut cmd = Command::new("zenity");
|
||||
cmd.args(["--file-selection", "--title", title]);
|
||||
for f in filters {
|
||||
if f.extensions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let patterns = f
|
||||
.extensions
|
||||
.iter()
|
||||
.map(|e| format!("*.{}", e.trim_start_matches('.')))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
cmd.args(["--file-filter", &format!("{} | {}", f.name, patterns)]);
|
||||
}
|
||||
let out = cmd.output().map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(path))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn try_kdialog(title: &str, filters: &[FileFilter]) -> Result<Option<String>, String> {
|
||||
let mut filter = String::new();
|
||||
for f in filters {
|
||||
if f.extensions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let patterns = f
|
||||
.extensions
|
||||
.iter()
|
||||
.map(|e| format!("*.{}", e.trim_start_matches('.')))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if !filter.is_empty() {
|
||||
filter.push('\n');
|
||||
}
|
||||
filter.push_str(&format!("{} ({})", patterns, f.name));
|
||||
}
|
||||
let mut cmd = Command::new("kdialog");
|
||||
cmd.args(["--getopenfilename", ".", &filter, "--title", title]);
|
||||
let out = cmd.output().map_err(|e| e.to_string())?;
|
||||
if !out.status.success() {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = String::from_utf8_lossy(&out.stdout).trim().to_string();
|
||||
if path.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(path))
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
use crate::{append_log_line, SessionInner};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
|
||||
const TARGET_TRIPLE: &str = env!("ORBIEN_TARGET_TRIPLE");
|
||||
|
||||
pub fn resolve_orbien(override_path: &str) -> Result<PathBuf, String> {
|
||||
if !override_path.trim().is_empty() {
|
||||
let p = PathBuf::from(override_path.trim());
|
||||
if p.is_file() {
|
||||
return Ok(p);
|
||||
}
|
||||
return Err(format!(
|
||||
"orbien not found at configured path: {}",
|
||||
p.display()
|
||||
));
|
||||
}
|
||||
|
||||
if let Ok(env_path) = std::env::var("ORBIEN_PATH") {
|
||||
let p = PathBuf::from(env_path.trim());
|
||||
if p.is_file() {
|
||||
return Ok(p);
|
||||
}
|
||||
}
|
||||
|
||||
let mut candidates: Vec<PathBuf> = Vec::new();
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
let exe = exe.canonicalize().unwrap_or(exe);
|
||||
if let Some(dir) = exe.parent() {
|
||||
candidates.push(dir.join(format!("orbien{}", exe_suffix())));
|
||||
candidates.push(dir.join(format!(
|
||||
"orbien-{}{}",
|
||||
TARGET_TRIPLE,
|
||||
exe_suffix()
|
||||
)));
|
||||
if let Some(contents) = dir.parent() {
|
||||
candidates.push(
|
||||
contents
|
||||
.join("Resources")
|
||||
.join(format!("orbien{}", exe_suffix())),
|
||||
);
|
||||
candidates.push(contents.join("MacOS").join(format!("orbien{}", exe_suffix())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.push(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(format!(
|
||||
"binaries/orbien-{}{}",
|
||||
TARGET_TRIPLE,
|
||||
exe_suffix()
|
||||
)));
|
||||
|
||||
let rels = [
|
||||
"orbien",
|
||||
"orbien.exe",
|
||||
"target/release/orbien",
|
||||
"target/debug/orbien",
|
||||
"target/release/orbien.exe",
|
||||
"target/debug/orbien.exe",
|
||||
"dist/orbien",
|
||||
"dist/orbien.exe",
|
||||
];
|
||||
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
for rel in rels {
|
||||
candidates.push(cwd.join(rel));
|
||||
}
|
||||
if let Some(found) = find_in_ancestors(&cwd, "target/release/orbien") {
|
||||
candidates.push(found);
|
||||
}
|
||||
if let Some(found) = find_in_ancestors(&cwd, "target/debug/orbien") {
|
||||
candidates.push(found);
|
||||
}
|
||||
if let Some(found) = find_in_ancestors(&cwd, "dist/orbien") {
|
||||
candidates.push(found);
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
if let Some(found) = find_in_ancestors(dir, "target/release/orbien") {
|
||||
candidates.push(found);
|
||||
}
|
||||
if let Some(found) = find_in_ancestors(dir, "dist/orbien") {
|
||||
candidates.push(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for c in &candidates {
|
||||
if c.is_file() {
|
||||
return Ok(c.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(path) = which("orbien") {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
Err(
|
||||
"orbien sidecar not found next to the app. Reinstall the desktop package, \
|
||||
or set Config → Orbien Binary Path to your local `orbien` (e.g. \
|
||||
/path/to/target/release/orbien)."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn exe_suffix() -> &'static str {
|
||||
if TARGET_TRIPLE.contains("windows") {
|
||||
".exe"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
fn find_in_ancestors(start: &Path, rel: &str) -> Option<PathBuf> {
|
||||
let mut dir = start.to_path_buf();
|
||||
for _ in 0..10 {
|
||||
let cand = dir.join(rel);
|
||||
if cand.is_file() {
|
||||
return Some(cand);
|
||||
}
|
||||
if !dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn which(name: &str) -> Result<PathBuf, ()> {
|
||||
let Ok(path_env) = std::env::var("PATH") else {
|
||||
return Err(());
|
||||
};
|
||||
for dir in std::env::split_paths(&path_env) {
|
||||
let p = dir.join(name);
|
||||
if p.is_file() {
|
||||
return Ok(p);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let p_exe = dir.join(format!("{name}.exe"));
|
||||
if p_exe.is_file() {
|
||||
return Ok(p_exe);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
|
||||
pub fn spawn_orbien(
|
||||
bin: &Path,
|
||||
config: &Path,
|
||||
logs: Arc<Mutex<SessionInner>>,
|
||||
) -> Result<Child, String> {
|
||||
let mut child = Command::new(bin)
|
||||
.arg("-c")
|
||||
.arg(config)
|
||||
.env("NO_COLOR", "1")
|
||||
.env("RUST_LOG_STYLE", "never")
|
||||
.env("CLICOLOR", "0")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null())
|
||||
.spawn()
|
||||
.map_err(|e| format!("{}: {e}", bin.display()))?;
|
||||
|
||||
if let Some(out) = child.stdout.take() {
|
||||
let logs_o = Arc::clone(&logs);
|
||||
thread::spawn(move || {
|
||||
let reader = BufReader::new(out);
|
||||
for line in reader.lines().flatten() {
|
||||
append_log_line(&logs_o, format!("[orbien] {line}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
let logs_e = Arc::clone(&logs);
|
||||
thread::spawn(move || {
|
||||
let reader = BufReader::new(err);
|
||||
for line in reader.lines().flatten() {
|
||||
append_log_line(&logs_e, format!("[orbien:err] {line}"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
pub fn stop_child(mut child: Child) -> Result<(), String> {
|
||||
child.kill().map_err(|e| e.to_string())?;
|
||||
let _ = child.wait();
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Orbien Desktop",
|
||||
"version": "2.1.0-SNAPSHOT",
|
||||
"identifier": "com.orbien.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Orbien Desktop",
|
||||
"width": 1100,
|
||||
"height": 720,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["dmg", "msi", "deb"],
|
||||
"externalBin": ["binaries/orbien"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"windows": {
|
||||
"wix": {
|
||||
"language": "en-US"
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": "-",
|
||||
"hardenedRuntime": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {RouterView} from "vue-router";
|
||||
import {computed} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import AppSidebar from "@/components/AppSidebar.vue";
|
||||
import {isTauriRuntime} from "@/api/desktop";
|
||||
import type {MessageSchema} from "@/i18n";
|
||||
|
||||
const {t} = useI18n<{ message: MessageSchema }>();
|
||||
const showBrowserWarning = computed(() => !isTauriRuntime());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shell">
|
||||
<AppSidebar/>
|
||||
<main class="main">
|
||||
<p v-if="showBrowserWarning" class="ipc-banner">{{ t("app.browserWarning") }}</p>
|
||||
<RouterView/>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ipc-banner {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius);
|
||||
background: #fff1f0;
|
||||
color: #cf1322;
|
||||
border: 1px solid #ffa39e;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,132 +0,0 @@
|
||||
import {invoke as tauriInvoke} from "@tauri-apps/api/core";
|
||||
import type {ClientConfig, ProxyConfig, ProxyItem} from "./types";
|
||||
|
||||
export type {
|
||||
ClientConfig,
|
||||
ProxyConfig,
|
||||
ProxyItem,
|
||||
QuicConfig,
|
||||
TlsConfig,
|
||||
} from "./types";
|
||||
export {
|
||||
defaultClientForm,
|
||||
defaultProxyForm,
|
||||
defaultProxyPlugin,
|
||||
defaultProxyTransport,
|
||||
defaultQuicConfig,
|
||||
defaultTlsConfig,
|
||||
joinList,
|
||||
normalizeProxyFromServer,
|
||||
splitList,
|
||||
} from "./types";
|
||||
|
||||
export interface ClientStatus {
|
||||
running: boolean;
|
||||
runningSecs: number;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface RuntimeStats {
|
||||
cpuPercent: number;
|
||||
memoryMb: number;
|
||||
version: string;
|
||||
}
|
||||
|
||||
export function isTauriRuntime(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
!!(window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTauri(timeoutMs = 3000): Promise<boolean> {
|
||||
if (isTauriRuntime()) return true;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
if (isTauriRuntime()) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const ok = await waitForTauri();
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
"Tauri IPC 不可用:请使用桌面窗口(npm run tauri dev),不要直接打开浏览器里的 http://localhost:1420",
|
||||
);
|
||||
}
|
||||
return tauriInvoke<T>(cmd, args);
|
||||
}
|
||||
|
||||
export function getStatus() {
|
||||
return invoke<ClientStatus>("get_status");
|
||||
}
|
||||
|
||||
export function getConfig() {
|
||||
return invoke<ClientConfig>("get_config");
|
||||
}
|
||||
|
||||
export interface SaveConfigResult {
|
||||
config: ClientConfig;
|
||||
restarted: boolean;
|
||||
}
|
||||
|
||||
export interface SaveProxiesResult {
|
||||
proxies: ProxyItem[];
|
||||
restarted: boolean;
|
||||
}
|
||||
|
||||
export function saveClientConfig(config: ClientConfig) {
|
||||
return invoke<SaveConfigResult>("save_client_config", {config});
|
||||
}
|
||||
|
||||
export function startClient() {
|
||||
return invoke<ClientStatus>("start_client");
|
||||
}
|
||||
|
||||
export function stopClient() {
|
||||
return invoke<ClientStatus>("stop_client");
|
||||
}
|
||||
|
||||
export interface LogsSnapshot {
|
||||
rev: number;
|
||||
lines: string[] | null;
|
||||
}
|
||||
|
||||
export function getLogs(sinceRev = 0) {
|
||||
return invoke<LogsSnapshot>("get_logs", {sinceRev});
|
||||
}
|
||||
|
||||
export function clearLogs() {
|
||||
return invoke<void>("clear_logs");
|
||||
}
|
||||
|
||||
export function getRuntimeStats() {
|
||||
return invoke<RuntimeStats>("get_runtime_stats");
|
||||
}
|
||||
|
||||
export function listProxies() {
|
||||
return invoke<ProxyItem[]>("list_proxies");
|
||||
}
|
||||
|
||||
export function saveProxies(proxies: ProxyConfig[]) {
|
||||
return invoke<SaveProxiesResult>("save_proxies", {proxies});
|
||||
}
|
||||
|
||||
export interface FileFilter {
|
||||
name: string;
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export interface PickFileOptions {
|
||||
title?: string;
|
||||
filters?: FileFilter[];
|
||||
}
|
||||
|
||||
export function pickFile(opts?: PickFileOptions) {
|
||||
return invoke<string | null>("pick_file", {
|
||||
title: opts?.title,
|
||||
filters: opts?.filters,
|
||||
});
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
export interface ProxyTransportOptions {
|
||||
bandwidthLimit: string;
|
||||
bandwidthLimitMode: string;
|
||||
proxyProtocolVersion: string;
|
||||
}
|
||||
|
||||
export interface ProxyPluginConfig {
|
||||
type: string;
|
||||
localAddr: string;
|
||||
crtPath: string;
|
||||
keyPath: string;
|
||||
hostHeaderRewrite: string;
|
||||
}
|
||||
|
||||
export interface ProxyConfig {
|
||||
name: string;
|
||||
proxyType: string;
|
||||
localIp: string;
|
||||
localPort: number;
|
||||
remotePort: number;
|
||||
customDomains: string[];
|
||||
subdomain: string;
|
||||
locations: string[];
|
||||
httpUser: string;
|
||||
httpPassword: string;
|
||||
hostHeaderRewrite: string;
|
||||
routeByHttpUser: string;
|
||||
transport: ProxyTransportOptions;
|
||||
plugin?: ProxyPluginConfig | null;
|
||||
}
|
||||
|
||||
export interface ProxyItem {
|
||||
name: string;
|
||||
proxyType: string;
|
||||
local: string;
|
||||
remote: string;
|
||||
copyValue: string;
|
||||
}
|
||||
|
||||
export interface TlsConfig {
|
||||
enable: boolean;
|
||||
certFile: string;
|
||||
keyFile: string;
|
||||
trustedCaFile: string;
|
||||
serverName: string;
|
||||
disableCustomTlsFirstByte: boolean;
|
||||
}
|
||||
|
||||
export interface QuicConfig {
|
||||
keepalivePeriod: number;
|
||||
maxIdleTimeout: number;
|
||||
maxIncomingStreams: number;
|
||||
}
|
||||
|
||||
export interface ClientConfig {
|
||||
serverAddr: string;
|
||||
serverPort: number;
|
||||
user: string;
|
||||
token: string;
|
||||
udpPacketSize: number;
|
||||
protocol: string;
|
||||
poolCount: number;
|
||||
tcpMux: boolean;
|
||||
tcpMuxKeepaliveInterval: number;
|
||||
heartbeatInterval: number;
|
||||
heartbeatTimeout: number;
|
||||
tls: TlsConfig;
|
||||
quic: QuicConfig;
|
||||
orbienPath: string;
|
||||
proxies: ProxyConfig[];
|
||||
}
|
||||
|
||||
export function defaultTlsConfig(): TlsConfig {
|
||||
return {
|
||||
enable: true,
|
||||
certFile: "",
|
||||
keyFile: "",
|
||||
trustedCaFile: "",
|
||||
serverName: "",
|
||||
disableCustomTlsFirstByte: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultQuicConfig(): QuicConfig {
|
||||
return {
|
||||
keepalivePeriod: 10,
|
||||
maxIdleTimeout: 30,
|
||||
maxIncomingStreams: 100000,
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultProxyTransport(): ProxyTransportOptions {
|
||||
return {
|
||||
bandwidthLimit: "",
|
||||
bandwidthLimitMode: "client",
|
||||
proxyProtocolVersion: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultProxyPlugin(): ProxyPluginConfig {
|
||||
return {
|
||||
type: "https2http",
|
||||
localAddr: "127.0.0.1:80",
|
||||
crtPath: "",
|
||||
keyPath: "",
|
||||
hostHeaderRewrite: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function defaultProxyForm(type: string = "tcp"): ProxyConfig {
|
||||
const base: ProxyConfig = {
|
||||
name: "",
|
||||
proxyType: type,
|
||||
localIp: "127.0.0.1",
|
||||
localPort: type === "https" ? 443 : type === "udp" ? 12001 : 8080,
|
||||
remotePort: type === "udp" ? 7001 : 6000,
|
||||
customDomains: [],
|
||||
subdomain: "",
|
||||
locations: [],
|
||||
httpUser: "",
|
||||
httpPassword: "",
|
||||
hostHeaderRewrite: "",
|
||||
routeByHttpUser: "",
|
||||
transport: defaultProxyTransport(),
|
||||
plugin: null,
|
||||
};
|
||||
if (type === "http") {
|
||||
base.localPort = 80;
|
||||
base.remotePort = 0;
|
||||
}
|
||||
if (type === "https") {
|
||||
base.remotePort = 0;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function defaultClientForm(): Omit<ClientConfig, "proxies"> {
|
||||
return {
|
||||
serverAddr: "127.0.0.1",
|
||||
serverPort: 9527,
|
||||
user: "",
|
||||
token: "",
|
||||
udpPacketSize: 1500,
|
||||
protocol: "tcp",
|
||||
poolCount: 1,
|
||||
tcpMux: true,
|
||||
tcpMuxKeepaliveInterval: 30,
|
||||
heartbeatInterval: -1,
|
||||
heartbeatTimeout: -1,
|
||||
tls: defaultTlsConfig(),
|
||||
quic: defaultQuicConfig(),
|
||||
orbienPath: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function splitList(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function joinList(items: string[] | undefined): string {
|
||||
return (items ?? []).join(", ");
|
||||
}
|
||||
|
||||
export function normalizeProxyFromServer(p: ProxyConfig): ProxyConfig {
|
||||
return {
|
||||
...defaultProxyForm(p.proxyType || "tcp"),
|
||||
...p,
|
||||
customDomains: p.customDomains ?? [],
|
||||
locations: p.locations ?? [],
|
||||
transport: { ...defaultProxyTransport(), ...(p.transport ?? {}) },
|
||||
plugin: p.plugin?.type
|
||||
? { ...defaultProxyPlugin(), ...p.plugin }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
Before Width: | Height: | Size: 205 KiB |
@@ -1,216 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {computed} from "vue";
|
||||
import {RouterLink, useRoute} from "vue-router";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import logoUrl from "@/assets/logo.png";
|
||||
import {
|
||||
LOCALE_META,
|
||||
setLocale,
|
||||
SUPPORTED_LOCALES,
|
||||
type AppLocale,
|
||||
type MessageSchema,
|
||||
} from "@/i18n";
|
||||
|
||||
const route = useRoute();
|
||||
const {t, locale} = useI18n<{ message: MessageSchema }, AppLocale>();
|
||||
|
||||
const items = computed(() => [
|
||||
{to: "/launch", label: t("nav.launch"), icon: "rocket" as const},
|
||||
{to: "/proxy", label: t("nav.proxy"), icon: "cloud" as const},
|
||||
{to: "/config", label: t("nav.config"), icon: "gear" as const},
|
||||
{to: "/logger", label: t("nav.logger"), icon: "doc" as const},
|
||||
]);
|
||||
|
||||
function onLocaleChange(e: Event) {
|
||||
const value = (e.target as HTMLSelectElement).value as AppLocale;
|
||||
setLocale(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img class="logo" :src="logoUrl" :alt="t('app.brand')"/>
|
||||
<div class="brand-name" :aria-label="t('app.brand')">
|
||||
<span class="brand-base">Orbi</span><span class="brand-accent">en</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<RouterLink
|
||||
v-for="item in items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="nav-item"
|
||||
:class="{ active: route.path === item.to }"
|
||||
:title="item.label"
|
||||
>
|
||||
<svg v-if="item.icon === 'rocket'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3c4 2 6 6 6 10l-3 1-2 4-2-4-3-1c0-4 2-8 6-10z"/>
|
||||
<path d="M9 14l-3 5M15 14l3 5"/>
|
||||
</svg>
|
||||
<svg v-else-if="item.icon === 'cloud'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 18h10a4 4 0 0 0 .3-8 5.5 5.5 0 0 0-10.6 1.5A3.5 3.5 0 0 0 7 18z"/>
|
||||
</svg>
|
||||
<svg v-else-if="item.icon === 'gear'" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path
|
||||
d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 3h7l3 3v15H7z"/>
|
||||
<path d="M14 3v4h4M9 12h6M9 16h6"/>
|
||||
</svg>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="footer">
|
||||
<label class="locale">
|
||||
<span class="sr-only">{{ t("locale.label") }}</span>
|
||||
<select
|
||||
class="locale-select"
|
||||
:value="locale"
|
||||
:aria-label="t('locale.label')"
|
||||
@change="onLocaleChange"
|
||||
>
|
||||
<option v-for="code in SUPPORTED_LOCALES" :key="code" :value="code">
|
||||
{{ LOCALE_META[code].nativeLabel }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--chrome);
|
||||
border-right: 1px solid var(--line);
|
||||
padding: 0.85rem 0.45rem 0.75rem;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.brand-base {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.brand-accent {
|
||||
background: var(--brand-grad);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--nav-idle);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-item svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.footer {
|
||||
width: 100%;
|
||||
padding-top: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.locale {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.locale-select {
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
width: 3.4rem;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--text-secondary);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.2rem 0.15rem;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.locale-select:hover,
|
||||
.locale-select:focus {
|
||||
border-color: var(--accent-muted);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {pickFile, type FileFilter} from "@/api/desktop";
|
||||
import type {MessageSchema} from "@/i18n";
|
||||
|
||||
const model = defineModel<string>({default: ""});
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
filters?: FileFilter[];
|
||||
}>(),
|
||||
{
|
||||
placeholder: "",
|
||||
filters: () => [
|
||||
{name: "Certificate / Key", extensions: ["pem", "crt", "cer", "key", "pub"]},
|
||||
{name: "All files", extensions: ["*"]},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const {t} = useI18n<{ message: MessageSchema }>();
|
||||
|
||||
async function browse() {
|
||||
try {
|
||||
const path = await pickFile({
|
||||
title: props.label,
|
||||
filters: props.filters,
|
||||
});
|
||||
if (path) model.value = path;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="field path-field">
|
||||
<span>{{ label }}</span>
|
||||
<div class="path-row">
|
||||
<input v-model="model" :placeholder="placeholder"/>
|
||||
<button class="browse-btn" type="button" @click="browse">
|
||||
{{ t("common.browse") }}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.path-row {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-row input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-row input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.browse-btn {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.85rem;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browse-btn:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
</style>
|
||||
@@ -1,62 +0,0 @@
|
||||
import {createI18n} from "vue-i18n";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
isAppLocale,
|
||||
LOCALE_META,
|
||||
type AppLocale,
|
||||
} from "./locales";
|
||||
import type {MessageSchema} from "./schema";
|
||||
import enUS from "./messages/en-US";
|
||||
import zhCN from "./messages/zh-CN";
|
||||
|
||||
const STORAGE_KEY = "orbien-desktop-locale";
|
||||
|
||||
function detectLocale(): AppLocale {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved && isAppLocale(saved)) return saved;
|
||||
} catch {
|
||||
|
||||
}
|
||||
const nav = (navigator.language || "").toLowerCase();
|
||||
if (nav.startsWith("zh")) return "zh-CN";
|
||||
if (nav.startsWith("en")) return "en-US";
|
||||
return DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
const initialLocale = detectLocale();
|
||||
|
||||
export const i18n = createI18n<[MessageSchema], AppLocale>({
|
||||
legacy: false,
|
||||
locale: initialLocale,
|
||||
fallbackLocale: "en-US",
|
||||
messages: {
|
||||
"zh-CN": zhCN,
|
||||
"en-US": enUS,
|
||||
},
|
||||
});
|
||||
|
||||
export function applyDocumentLocale(locale: AppLocale) {
|
||||
document.documentElement.lang = LOCALE_META[locale].htmlLang;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, locale);
|
||||
} catch {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
applyDocumentLocale(initialLocale);
|
||||
|
||||
export function setLocale(locale: AppLocale) {
|
||||
const current = i18n.global.locale as unknown as AppLocale | { value: AppLocale };
|
||||
if (typeof current === "object" && current !== null && "value" in current) {
|
||||
current.value = locale;
|
||||
} else {
|
||||
(i18n.global as unknown as { locale: AppLocale }).locale = locale;
|
||||
}
|
||||
applyDocumentLocale(locale);
|
||||
}
|
||||
|
||||
export {LOCALE_META, SUPPORTED_LOCALES, DEFAULT_LOCALE, isAppLocale} from "./locales";
|
||||
export type {AppLocale} from "./locales";
|
||||
export type {MessageSchema} from "./schema";
|
||||
@@ -1,17 +0,0 @@
|
||||
export const SUPPORTED_LOCALES = ["zh-CN", "en-US"] as const;
|
||||
|
||||
export type AppLocale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
export const DEFAULT_LOCALE: AppLocale = "zh-CN";
|
||||
|
||||
export const LOCALE_META: Record<
|
||||
AppLocale,
|
||||
{ label: string; nativeLabel: string; htmlLang: string }
|
||||
> = {
|
||||
"zh-CN": {label: "Chinese", nativeLabel: "中文", htmlLang: "zh-CN"},
|
||||
"en-US": {label: "English", nativeLabel: "EN", htmlLang: "en"},
|
||||
};
|
||||
|
||||
export function isAppLocale(value: string): value is AppLocale {
|
||||
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import type {MessageSchema} from "../schema";
|
||||
|
||||
const enUS: MessageSchema = {
|
||||
app: {
|
||||
brand: "Orbien",
|
||||
browserWarning:
|
||||
"This looks like a normal browser tab. Use the Orbien Desktop window from `npm run tauri dev` — do not open localhost:1420 directly.",
|
||||
},
|
||||
nav: {
|
||||
launch: "Launch",
|
||||
proxy: "Proxy",
|
||||
config: "Config",
|
||||
logger: "Logger",
|
||||
},
|
||||
common: {
|
||||
memory: "Mem",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
browse: "Browse",
|
||||
clear: "Clear",
|
||||
searchLogs: "Search logs…",
|
||||
autoScroll: "Auto scroll",
|
||||
emptyLogs: "No logs yet",
|
||||
emptyProxies: "No proxies yet — tap + to add one",
|
||||
modify: "Modify",
|
||||
more: "More",
|
||||
delete: "Delete",
|
||||
addProxy: "Add proxy",
|
||||
},
|
||||
launch: {
|
||||
title: "Launch",
|
||||
running: "Orbien Running",
|
||||
stopped: "Orbien Stopped",
|
||||
runningTime: "Running Time",
|
||||
viewLog: "View Log",
|
||||
start: "Start",
|
||||
stop: "Stop",
|
||||
seconds: "{n}s",
|
||||
minutesSecs: "{m}m {s}s",
|
||||
},
|
||||
proxy: {
|
||||
title: "Proxy",
|
||||
inner: "Local",
|
||||
remote: "Remote",
|
||||
copy: "Copy",
|
||||
copied: "Copied",
|
||||
addTitle: "Add proxy",
|
||||
editTitle: "Edit proxy",
|
||||
saved: "Proxies saved (will apply on next Start)",
|
||||
savedAndApplied: "Proxies saved; client restarted to sync with the server",
|
||||
basicSection: "Basics",
|
||||
localSection: "Backend service",
|
||||
remoteSection: "Remote Port",
|
||||
vhostSection: "Domain Routing",
|
||||
pluginSection: "Client Plugin",
|
||||
transportSection: "Bandwidth / Real IP",
|
||||
showAdvanced: "Show advanced",
|
||||
hideAdvanced: "Hide advanced",
|
||||
name: "Name",
|
||||
nameHint: "e.g. web, mysql",
|
||||
type: "Type",
|
||||
typeHint: {
|
||||
tcp: "Expose TCP by remote port",
|
||||
udp: "Expose UDP by remote port",
|
||||
http: "Route by Host via server HTTP vhost",
|
||||
https: "Route by SNI via server HTTPS vhost",
|
||||
},
|
||||
localIp: "Service IP",
|
||||
localIpHint: "Usually 127.0.0.1",
|
||||
localPort: "Service port",
|
||||
localPortHint: "Backend service port",
|
||||
remotePort: "Remote port",
|
||||
remotePortHint: "Public port on the server",
|
||||
customDomains: "Domain",
|
||||
customDomainsHint: "example.com, comma-separated",
|
||||
subdomain: "Subdomain",
|
||||
subdomainHint: "Prefix only; becomes prefix.root-domain",
|
||||
locations: "Path prefixes",
|
||||
locationsHint: "Only these paths, e.g. /api; comma-separated, empty = all",
|
||||
hostHeaderRewrite: "Host rewrite",
|
||||
hostHeaderRewriteHint: "Rewrite Host sent to the service, e.g. 127.0.0.1; empty = keep",
|
||||
pluginHostRewriteHint: "Rewrite Host sent to the service, e.g. 127.0.0.1; empty = keep",
|
||||
useHttps2Http: "Use https2http",
|
||||
useHttps2HttpHint: "Terminate TLS on the client, forward to HTTP service",
|
||||
httpsMode: "TLS handling",
|
||||
httpsModePassthrough: "Passthrough",
|
||||
httpsModePlugin: "Terminate",
|
||||
pluginLocalAddr: "Service address",
|
||||
pluginLocalAddrHint: "127.0.0.1:80",
|
||||
pluginCrt: "Certificate path",
|
||||
pluginCrtHint: "Empty = ephemeral self-signed",
|
||||
pluginKey: "Private key path",
|
||||
pluginKeyHint: "Empty = ephemeral self-signed",
|
||||
bandwidthLimit: "Bandwidth limit",
|
||||
bandwidthLimitHint: "e.g. 1MB; empty = unlimited",
|
||||
bandwidthMode: "Limit side",
|
||||
bandwidthModeHint: "client / server",
|
||||
proxyProtocol: "PROXY Protocol",
|
||||
proxyProtocolOff: "Off",
|
||||
proxyProtocolHint: "Write real IP to the service",
|
||||
pathHint: "File path",
|
||||
nameRequired: "Proxy name is required",
|
||||
nameExists: "Proxy name already exists",
|
||||
remotePortRequired: "Remote port is required",
|
||||
localPortRequired: "Service port is required",
|
||||
domainRequired: "HTTP/HTTPS requires custom domains or a subdomain",
|
||||
pluginLocalAddrRequired: "Service address is required",
|
||||
},
|
||||
config: {
|
||||
title: "Config",
|
||||
saved: "Config saved (will apply on next Start)",
|
||||
savedAndApplied: "Config saved; client restarted to sync with the server",
|
||||
resetDefaults: "Reset defaults",
|
||||
resetConfirm:
|
||||
"Restore connection and transport defaults and save now (proxies are kept).",
|
||||
resetConfirmAction: "Confirm reset",
|
||||
resetDone: "Defaults restored and saved",
|
||||
serverSection: "Server Connection",
|
||||
transportSection: "Transport",
|
||||
tlsSection: "TLS Encryption",
|
||||
quicSection: "QUIC Options",
|
||||
desktopSection: "Desktop",
|
||||
showAdvanced: "Show advanced",
|
||||
hideAdvanced: "Hide advanced",
|
||||
serverAddr: "Server Address",
|
||||
serverAddrHint: "Public IP or domain, e.g. 1.2.3.4 or orbien.example.com",
|
||||
serverPort: "Server Port",
|
||||
serverPortHint: "bindPort for TCP/WebSocket; use quicBindPort / kcpBindPort for QUIC/KCP",
|
||||
user: "Client Username",
|
||||
userHint: "Optional",
|
||||
token: "Auth Token",
|
||||
tokenHint: "Optional",
|
||||
protocol: "Transport Protocol",
|
||||
protocolHint: "Control-channel protocol between client and server — not the proxy type",
|
||||
poolCount: "Connection Pool Count",
|
||||
poolCountHint: "Work connections/streams prefetched at login (default 1)",
|
||||
tcpMux: "TCP Multiplexing",
|
||||
tcpMuxHint: "One physical connection carries many streams; ignored for QUIC. Must match server",
|
||||
tcpMuxKeepalive: "Mux Keepalive (seconds)",
|
||||
tcpMuxKeepaliveHint: "Yamux keepalive interval (default 30)",
|
||||
heartbeatInterval: "App Heartbeat Interval (seconds)",
|
||||
heartbeatIntervalHint: "Leave empty to disable (mux keepalive when mux is on); typically 30 when mux is off",
|
||||
heartbeatTimeout: "App Heartbeat Timeout (seconds)",
|
||||
heartbeatTimeoutHint: "Leave empty to disable; typically 90 when mux is off",
|
||||
udpPacketSize: "UDP Packet Size",
|
||||
udpPacketSizeHint: "Max UDP datagram size; must match server (default 1500)",
|
||||
tlsEnable: "Enable Transport TLS",
|
||||
tlsEnableHint: "Wrap TCP / WebSocket / KCP with TLS after dial; QUIC is always encrypted",
|
||||
tlsServerName: "TLS Server Name (SNI)",
|
||||
tlsServerNameHint: "Hostname for certificate verification; empty falls back to server address",
|
||||
tlsTrustedCa: "Trusted CA Certificate",
|
||||
tlsTrustedCaHint: "Verify the server cert; empty skips verification (encrypt-only)",
|
||||
tlsCert: "Client Certificate",
|
||||
tlsCertHint: "Client cert path for mutual TLS",
|
||||
tlsKey: "Client Private Key",
|
||||
tlsKeyHint: "Private key paired with the client certificate",
|
||||
tlsDisableFirstByte: "Disable Custom TLS First Byte",
|
||||
tlsDisableFirstByteHint: "On by default. Turn off if HTTPS vhost shares bindPort",
|
||||
quicKeepalive: "QUIC Keepalive Period (seconds)",
|
||||
quicKeepaliveHint: "Default 10",
|
||||
quicIdle: "QUIC Idle Timeout (seconds)",
|
||||
quicIdleHint: "Default 30",
|
||||
quicStreams: "QUIC Max Incoming Streams",
|
||||
quicStreamsHint: "Default 100000",
|
||||
pathHint: "Absolute or relative path",
|
||||
optionalEmpty: "Leave empty to disable",
|
||||
orbienPath: "Orbien Binary Path",
|
||||
orbienPathHint: "Leave empty to use the bundled sidecar (optional override)",
|
||||
},
|
||||
logger: {
|
||||
title: "Logger",
|
||||
appLog: "App Log",
|
||||
orbienLog: "Orbien Log",
|
||||
},
|
||||
locale: {
|
||||
label: "Language",
|
||||
},
|
||||
};
|
||||
|
||||
export default enUS;
|
||||
@@ -1,179 +0,0 @@
|
||||
import type {MessageSchema} from "../schema";
|
||||
|
||||
const zhCN: MessageSchema = {
|
||||
app: {
|
||||
brand: "Orbien",
|
||||
browserWarning:
|
||||
"当前像是在普通浏览器里打开。请使用 `npm run tauri dev` 弹出的 Orbien Desktop 窗口,不要访问 localhost:1420。",
|
||||
},
|
||||
nav: {
|
||||
launch: "启动",
|
||||
proxy: "代理",
|
||||
config: "配置",
|
||||
logger: "日志",
|
||||
},
|
||||
common: {
|
||||
memory: "内存",
|
||||
save: "保存",
|
||||
cancel: "取消",
|
||||
browse: "选择",
|
||||
clear: "清空",
|
||||
searchLogs: "搜索日志…",
|
||||
autoScroll: "自动滚动",
|
||||
emptyLogs: "暂无日志",
|
||||
emptyProxies: "暂无代理,点击右上角添加",
|
||||
modify: "修改",
|
||||
more: "更多",
|
||||
delete: "删除",
|
||||
addProxy: "添加代理",
|
||||
},
|
||||
launch: {
|
||||
title: "启动",
|
||||
running: "Orbien 运行中",
|
||||
stopped: "Orbien 已停止",
|
||||
runningTime: "运行时长",
|
||||
viewLog: "查看日志",
|
||||
start: "启动",
|
||||
stop: "停止",
|
||||
seconds: "{n} 秒",
|
||||
minutesSecs: "{m} 分 {s} 秒",
|
||||
},
|
||||
proxy: {
|
||||
title: "代理",
|
||||
inner: "本地",
|
||||
remote: "远程",
|
||||
copy: "复制",
|
||||
copied: "已复制",
|
||||
addTitle: "添加代理",
|
||||
editTitle: "修改代理",
|
||||
saved: "代理已保存(客户端未运行,启动后生效)",
|
||||
savedAndApplied: "代理已保存,并已重启客户端同步到服务端",
|
||||
basicSection: "基本信息",
|
||||
localSection: "后端服务",
|
||||
remoteSection: "远端端口",
|
||||
vhostSection: "域名路由",
|
||||
pluginSection: "客户端插件",
|
||||
transportSection: "传输限速 / 真实 IP",
|
||||
showAdvanced: "显示高级选项",
|
||||
hideAdvanced: "收起高级选项",
|
||||
name: "名称",
|
||||
nameHint: "例如 web、mysql",
|
||||
type: "类型",
|
||||
typeHint: {
|
||||
tcp: "按远端端口暴露 TCP",
|
||||
udp: "按远端端口暴露 UDP",
|
||||
http: "经服务端 HTTP 虚拟主机按域名路由",
|
||||
https: "经服务端 HTTPS 虚拟主机按 SNI 路由",
|
||||
},
|
||||
localIp: "服务 IP",
|
||||
localIpHint: "通常是 127.0.0.1",
|
||||
localPort: "服务端口",
|
||||
localPortHint: "后端服务端口",
|
||||
remotePort: "远端端口",
|
||||
remotePortHint: "服务端对外端口",
|
||||
customDomains: "域名",
|
||||
customDomainsHint: "example.com,多个用逗号分隔",
|
||||
subdomain: "子域名",
|
||||
subdomainHint: "只填前缀,与根域名拼接为「前缀.根域名」",
|
||||
locations: "路径前缀",
|
||||
locationsHint: "只转发这些路径,如 /api;多个用逗号,留空=全部",
|
||||
hostHeaderRewrite: "Host 重写",
|
||||
hostHeaderRewriteHint: "改写转发到服务的 Host,如 127.0.0.1;留空不改",
|
||||
pluginHostRewriteHint: "改写转发到服务的 Host,如 127.0.0.1;留空不改",
|
||||
useHttps2Http: "使用 https2http",
|
||||
useHttps2HttpHint: "客户端终止 TLS 后转到 HTTP 服务",
|
||||
httpsMode: "TLS 处理",
|
||||
httpsModePassthrough: "透传",
|
||||
httpsModePlugin: "终止",
|
||||
pluginLocalAddr: "服务地址",
|
||||
pluginLocalAddrHint: "127.0.0.1:80",
|
||||
pluginCrt: "证书路径",
|
||||
pluginCrtHint: "留空则临时自签",
|
||||
pluginKey: "私钥路径",
|
||||
pluginKeyHint: "留空则临时自签",
|
||||
bandwidthLimit: "带宽限制",
|
||||
bandwidthLimitHint: "如 1MB,留空不限制",
|
||||
bandwidthMode: "限速位置",
|
||||
bandwidthModeHint: "client / server",
|
||||
proxyProtocol: "PROXY Protocol",
|
||||
proxyProtocolOff: "关闭",
|
||||
proxyProtocolHint: "向服务写入真实 IP",
|
||||
pathHint: "文件路径",
|
||||
nameRequired: "请填写代理名称",
|
||||
nameExists: "代理名称已存在",
|
||||
remotePortRequired: "请填写远端端口",
|
||||
localPortRequired: "请填写服务端口",
|
||||
domainRequired: "HTTP/HTTPS 需填写自定义域名或子域名",
|
||||
pluginLocalAddrRequired: "请填写服务地址",
|
||||
},
|
||||
config: {
|
||||
title: "配置",
|
||||
saved: "配置已保存(客户端未运行,启动后生效)",
|
||||
savedAndApplied: "配置已保存,并已重启客户端同步到服务端",
|
||||
resetDefaults: "重置默认",
|
||||
resetConfirm: "将恢复连接与传输的默认配置并立即保存(不影响已添加的代理)。",
|
||||
resetConfirmAction: "确认重置",
|
||||
resetDone: "已恢复默认配置并保存",
|
||||
serverSection: "服务端连接",
|
||||
transportSection: "传输配置",
|
||||
tlsSection: "TLS 加密",
|
||||
quicSection: "QUIC 参数",
|
||||
desktopSection: "桌面端",
|
||||
showAdvanced: "显示高级选项",
|
||||
hideAdvanced: "收起高级选项",
|
||||
serverAddr: "服务端地址",
|
||||
serverAddrHint: "公网 IP 或域名,例如 1.2.3.4 或 orbien.example.com",
|
||||
serverPort: "服务端端口",
|
||||
serverPortHint: "TCP/WebSocket 为 bindPort;QUIC/KCP 需对应 quicBindPort / kcpBindPort",
|
||||
user: "客户端用户名",
|
||||
userHint: "可选",
|
||||
token: "认证 Token",
|
||||
tokenHint: "可选",
|
||||
protocol: "传输协议",
|
||||
protocolHint: "客户端与服务端之间的控制通道协议,不是代理类型",
|
||||
poolCount: "连接池数量",
|
||||
poolCountHint: "登录时预创建的工作连接/流数量,默认 1",
|
||||
tcpMux: "TCP 多路复用",
|
||||
tcpMuxHint: "启用后一条物理连接承载多路流;QUIC 自带多路复用,此项无效。需与服务端一致",
|
||||
tcpMuxKeepalive: "多路复用保活间隔(秒)",
|
||||
tcpMuxKeepaliveHint: "Yamux keepalive,默认 30 秒",
|
||||
heartbeatInterval: "应用心跳间隔(秒)",
|
||||
heartbeatIntervalHint: "留空关闭(多路复用时依赖 mux 保活);关闭多路复用时建议 30",
|
||||
heartbeatTimeout: "应用心跳超时(秒)",
|
||||
heartbeatTimeoutHint: "留空关闭;关闭多路复用时建议 90",
|
||||
udpPacketSize: "UDP 包大小",
|
||||
udpPacketSizeHint: "UDP 最大报文长度,需与服务端一致,默认 1500",
|
||||
tlsEnable: "启用传输层 TLS",
|
||||
tlsEnableHint: "对 TCP / WebSocket / KCP 在拨号后套一层 TLS;QUIC 本身已加密",
|
||||
tlsServerName: "TLS 服务器名称(SNI)",
|
||||
tlsServerNameHint: "证书校验用的域名;留空则使用服务端地址",
|
||||
tlsTrustedCa: "可信 CA 证书",
|
||||
tlsTrustedCaHint: "校验服务端证书;留空则跳过校验(仅加密)",
|
||||
tlsCert: "客户端证书",
|
||||
tlsCertHint: "双向认证时填写客户端证书路径",
|
||||
tlsKey: "客户端私钥",
|
||||
tlsKeyHint: "与客户端证书配对的私钥路径",
|
||||
tlsDisableFirstByte: "禁用自定义 TLS 首字节",
|
||||
tlsDisableFirstByteHint: "默认开启。若 HTTPS 虚拟主机与 bindPort 共用,需关闭此项",
|
||||
quicKeepalive: "QUIC 保活周期(秒)",
|
||||
quicKeepaliveHint: "默认 10",
|
||||
quicIdle: "QUIC 空闲超时(秒)",
|
||||
quicIdleHint: "默认 30",
|
||||
quicStreams: "QUIC 最大入站流",
|
||||
quicStreamsHint: "默认 100000",
|
||||
pathHint: "绝对路径或相对路径",
|
||||
optionalEmpty: "留空关闭",
|
||||
orbienPath: "Orbien 可执行文件路径",
|
||||
orbienPathHint: "留空使用安装包内置 sidecar(可选覆盖)",
|
||||
},
|
||||
logger: {
|
||||
title: "日志",
|
||||
appLog: "应用日志",
|
||||
orbienLog: "Orbien 日志",
|
||||
},
|
||||
locale: {
|
||||
label: "语言",
|
||||
},
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
@@ -1,174 +0,0 @@
|
||||
export interface MessageSchema {
|
||||
app: {
|
||||
brand: string
|
||||
browserWarning: string
|
||||
}
|
||||
nav: {
|
||||
launch: string
|
||||
proxy: string
|
||||
config: string
|
||||
logger: string
|
||||
}
|
||||
common: {
|
||||
memory: string
|
||||
save: string
|
||||
cancel: string
|
||||
browse: string
|
||||
clear: string
|
||||
searchLogs: string
|
||||
autoScroll: string
|
||||
emptyLogs: string
|
||||
emptyProxies: string
|
||||
modify: string
|
||||
more: string
|
||||
delete: string
|
||||
addProxy: string
|
||||
}
|
||||
launch: {
|
||||
title: string
|
||||
running: string
|
||||
stopped: string
|
||||
runningTime: string
|
||||
viewLog: string
|
||||
start: string
|
||||
stop: string
|
||||
seconds: string
|
||||
minutesSecs: string
|
||||
}
|
||||
proxy: {
|
||||
title: string
|
||||
inner: string
|
||||
remote: string
|
||||
copy: string
|
||||
copied: string
|
||||
addTitle: string
|
||||
editTitle: string
|
||||
saved: string
|
||||
savedAndApplied: string
|
||||
basicSection: string
|
||||
localSection: string
|
||||
remoteSection: string
|
||||
vhostSection: string
|
||||
pluginSection: string
|
||||
transportSection: string
|
||||
showAdvanced: string
|
||||
hideAdvanced: string
|
||||
name: string
|
||||
nameHint: string
|
||||
type: string
|
||||
typeHint: {
|
||||
tcp: string
|
||||
udp: string
|
||||
http: string
|
||||
https: string
|
||||
}
|
||||
localIp: string
|
||||
localIpHint: string
|
||||
localPort: string
|
||||
localPortHint: string
|
||||
remotePort: string
|
||||
remotePortHint: string
|
||||
customDomains: string
|
||||
customDomainsHint: string
|
||||
subdomain: string
|
||||
subdomainHint: string
|
||||
locations: string
|
||||
locationsHint: string
|
||||
hostHeaderRewrite: string
|
||||
hostHeaderRewriteHint: string
|
||||
pluginHostRewriteHint: string
|
||||
useHttps2Http: string
|
||||
useHttps2HttpHint: string
|
||||
httpsMode: string
|
||||
httpsModePassthrough: string
|
||||
httpsModePlugin: string
|
||||
pluginLocalAddr: string
|
||||
pluginLocalAddrHint: string
|
||||
pluginCrt: string
|
||||
pluginCrtHint: string
|
||||
pluginKey: string
|
||||
pluginKeyHint: string
|
||||
bandwidthLimit: string
|
||||
bandwidthLimitHint: string
|
||||
bandwidthMode: string
|
||||
bandwidthModeHint: string
|
||||
proxyProtocol: string
|
||||
proxyProtocolOff: string
|
||||
proxyProtocolHint: string
|
||||
pathHint: string
|
||||
nameRequired: string
|
||||
nameExists: string
|
||||
remotePortRequired: string
|
||||
localPortRequired: string
|
||||
domainRequired: string
|
||||
pluginLocalAddrRequired: string
|
||||
}
|
||||
config: {
|
||||
title: string
|
||||
saved: string
|
||||
savedAndApplied: string
|
||||
resetDefaults: string
|
||||
resetConfirm: string
|
||||
resetConfirmAction: string
|
||||
resetDone: string
|
||||
serverSection: string
|
||||
transportSection: string
|
||||
tlsSection: string
|
||||
quicSection: string
|
||||
desktopSection: string
|
||||
showAdvanced: string
|
||||
hideAdvanced: string
|
||||
serverAddr: string
|
||||
serverAddrHint: string
|
||||
serverPort: string
|
||||
serverPortHint: string
|
||||
user: string
|
||||
userHint: string
|
||||
token: string
|
||||
tokenHint: string
|
||||
protocol: string
|
||||
protocolHint: string
|
||||
poolCount: string
|
||||
poolCountHint: string
|
||||
tcpMux: string
|
||||
tcpMuxHint: string
|
||||
tcpMuxKeepalive: string
|
||||
tcpMuxKeepaliveHint: string
|
||||
heartbeatInterval: string
|
||||
heartbeatIntervalHint: string
|
||||
heartbeatTimeout: string
|
||||
heartbeatTimeoutHint: string
|
||||
udpPacketSize: string
|
||||
udpPacketSizeHint: string
|
||||
tlsEnable: string
|
||||
tlsEnableHint: string
|
||||
tlsServerName: string
|
||||
tlsServerNameHint: string
|
||||
tlsTrustedCa: string
|
||||
tlsTrustedCaHint: string
|
||||
tlsCert: string
|
||||
tlsCertHint: string
|
||||
tlsKey: string
|
||||
tlsKeyHint: string
|
||||
tlsDisableFirstByte: string
|
||||
tlsDisableFirstByteHint: string
|
||||
quicKeepalive: string
|
||||
quicKeepaliveHint: string
|
||||
quicIdle: string
|
||||
quicIdleHint: string
|
||||
quicStreams: string
|
||||
quicStreamsHint: string
|
||||
pathHint: string
|
||||
optionalEmpty: string
|
||||
orbienPath: string
|
||||
orbienPathHint: string
|
||||
}
|
||||
logger: {
|
||||
title: string
|
||||
appLog: string
|
||||
orbienLog: string
|
||||
}
|
||||
locale: {
|
||||
label: string
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import {createApp} from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import {i18n} from "./i18n";
|
||||
import "./styles/main.css";
|
||||
|
||||
createApp(App).use(router).use(i18n).mount("#app");
|
||||
@@ -1,19 +0,0 @@
|
||||
import {createRouter, createWebHashHistory} from "vue-router";
|
||||
import Launch from "@/views/Launch.vue";
|
||||
import Proxy from "@/views/Proxy.vue";
|
||||
import Config from "@/views/Config.vue";
|
||||
import Logger from "@/views/Logger.vue";
|
||||
|
||||
const router = createRouter({
|
||||
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{path: "/", redirect: "/launch"},
|
||||
{path: "/launch", name: "launch", component: Launch},
|
||||
{path: "/proxy", name: "proxy", component: Proxy},
|
||||
{path: "/config", name: "config", component: Config},
|
||||
{path: "/logger", name: "logger", component: Logger},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,169 +0,0 @@
|
||||
|
||||
:root {
|
||||
--sidebar-width: 72px;
|
||||
|
||||
--accent: #3b82f6;
|
||||
--accent-soft: rgba(59, 130, 246, 0.12);
|
||||
--accent-strong: #2563eb;
|
||||
--accent-muted: #60a5fa;
|
||||
--bg: #f0f5fa;
|
||||
--chrome: #f7fafc;
|
||||
--panel: #ffffff;
|
||||
--text: #1e293b;
|
||||
--text-secondary: #475569;
|
||||
--muted: #64748b;
|
||||
--line: #e2e8f0;
|
||||
--ok: #16a34a;
|
||||
--danger: #e11d48;
|
||||
--shadow: 0 8px 24px rgba(30, 58, 95, 0.06);
|
||||
--radius: 0.5rem;
|
||||
--brand-grad: linear-gradient(105deg, #0f172a 8%, #3b82f6 52%, #38bdf8 100%);
|
||||
--blob-a: rgba(59, 130, 246, 0.18);
|
||||
--blob-b: rgba(56, 189, 248, 0.14);
|
||||
--nav-idle: #64748b;
|
||||
font-family: "SF Pro Text", "Segoe UI", "PingFang SC", system-ui, sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
height: 100vh;
|
||||
background: var(--chrome);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
background: var(--bg);
|
||||
padding: 1.25rem 1.5rem 1.75rem;
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.page-title svg {
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 1.1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s ease,
|
||||
transform 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-strong);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.btn-icon svg {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
padding: 0.35rem 0.55rem;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
@@ -1,480 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, reactive, ref, watch} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {
|
||||
defaultClientForm,
|
||||
defaultQuicConfig,
|
||||
defaultTlsConfig,
|
||||
getConfig,
|
||||
saveClientConfig,
|
||||
type ClientConfig,
|
||||
type ProxyConfig,
|
||||
} from "@/api/desktop";
|
||||
import PathField from "@/components/PathField.vue";
|
||||
import type {MessageSchema} from "@/i18n";
|
||||
|
||||
const {t} = useI18n<{ message: MessageSchema }>();
|
||||
|
||||
const form = reactive(defaultClientForm());
|
||||
const proxies = ref<ProxyConfig[]>([]);
|
||||
|
||||
const error = ref("");
|
||||
const saving = ref(false);
|
||||
const showAdvanced = ref(false);
|
||||
const confirmReset = ref(false);
|
||||
let applyingConfig = false;
|
||||
|
||||
const showQuic = computed(() => form.protocol === "quic");
|
||||
const showMuxKeepalive = computed(
|
||||
() => form.tcpMux && form.protocol !== "quic",
|
||||
);
|
||||
|
||||
function formatOptionalSecs(value: number): string {
|
||||
return value < 0 ? "" : String(value);
|
||||
}
|
||||
|
||||
function parseOptionalSecs(raw: string): number {
|
||||
const text = raw.trim();
|
||||
if (text === "" || text === "-1") return -1;
|
||||
const n = Number(text);
|
||||
if (!Number.isFinite(n) || n < 0) return -1;
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
const heartbeatIntervalInput = computed({
|
||||
get: () => formatOptionalSecs(form.heartbeatInterval),
|
||||
set: (raw: string) => {
|
||||
form.heartbeatInterval = parseOptionalSecs(raw);
|
||||
},
|
||||
});
|
||||
|
||||
const heartbeatTimeoutInput = computed({
|
||||
get: () => formatOptionalSecs(form.heartbeatTimeout),
|
||||
set: (raw: string) => {
|
||||
form.heartbeatTimeout = parseOptionalSecs(raw);
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => form.tcpMux,
|
||||
(mux) => {
|
||||
if (applyingConfig) return;
|
||||
if (!mux) {
|
||||
if (form.heartbeatInterval < 0) form.heartbeatInterval = 30;
|
||||
if (form.heartbeatTimeout < 0) form.heartbeatTimeout = 90;
|
||||
} else {
|
||||
if (form.heartbeatInterval === 30) form.heartbeatInterval = -1;
|
||||
if (form.heartbeatTimeout === 90) form.heartbeatTimeout = -1;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function applyConfig(cfg: ClientConfig) {
|
||||
applyingConfig = true;
|
||||
try {
|
||||
const tls = {...defaultTlsConfig(), ...(cfg.tls ?? {})};
|
||||
const quic = {...defaultQuicConfig(), ...(cfg.quic ?? {})};
|
||||
Object.assign(form, {
|
||||
serverAddr: cfg.serverAddr,
|
||||
serverPort: cfg.serverPort,
|
||||
user: cfg.user ?? "",
|
||||
token: cfg.token,
|
||||
udpPacketSize: cfg.udpPacketSize ?? 1500,
|
||||
protocol: cfg.protocol,
|
||||
poolCount: cfg.poolCount ?? 1,
|
||||
tcpMux: cfg.tcpMux,
|
||||
tcpMuxKeepaliveInterval: cfg.tcpMuxKeepaliveInterval ?? 30,
|
||||
heartbeatInterval: cfg.heartbeatInterval ?? -1,
|
||||
heartbeatTimeout: cfg.heartbeatTimeout ?? -1,
|
||||
orbienPath: "",
|
||||
});
|
||||
Object.assign(form.tls, tls);
|
||||
Object.assign(form.quic, quic);
|
||||
proxies.value = cfg.proxies ?? [];
|
||||
} finally {
|
||||
applyingConfig = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
applyConfig(await getConfig());
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
}
|
||||
});
|
||||
|
||||
function cancelReset() {
|
||||
confirmReset.value = false;
|
||||
}
|
||||
|
||||
async function resetToDefaults() {
|
||||
if (!confirmReset.value) {
|
||||
confirmReset.value = true;
|
||||
error.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
confirmReset.value = false;
|
||||
const keptProxies = proxies.value;
|
||||
const defaults = defaultClientForm();
|
||||
applyConfig({
|
||||
...defaults,
|
||||
tls: {...defaults.tls},
|
||||
quic: {...defaults.quic},
|
||||
proxies: keptProxies,
|
||||
});
|
||||
showAdvanced.value = false;
|
||||
await persist();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
confirmReset.value = false;
|
||||
await persist();
|
||||
}
|
||||
|
||||
async function persist() {
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const payload: ClientConfig = {
|
||||
serverAddr: form.serverAddr,
|
||||
serverPort: form.serverPort,
|
||||
user: form.user,
|
||||
token: form.token,
|
||||
udpPacketSize: form.udpPacketSize,
|
||||
protocol: form.protocol,
|
||||
poolCount: form.poolCount,
|
||||
tcpMux: form.tcpMux,
|
||||
tcpMuxKeepaliveInterval: form.tcpMuxKeepaliveInterval,
|
||||
heartbeatInterval: form.heartbeatInterval,
|
||||
heartbeatTimeout: form.heartbeatTimeout,
|
||||
orbienPath: "",
|
||||
tls: {...form.tls},
|
||||
quic: {...form.quic},
|
||||
proxies: proxies.value,
|
||||
};
|
||||
const result = await saveClientConfig(payload);
|
||||
applyConfig(result.config);
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<h1 class="page-title">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path
|
||||
d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t("config.title") }}
|
||||
</h1>
|
||||
<div class="head-actions">
|
||||
<template v-if="confirmReset">
|
||||
<button class="btn btn-secondary" type="button" :disabled="saving" @click="cancelReset">
|
||||
{{ t("common.cancel") }}
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="saving" @click="resetToDefaults">
|
||||
{{ t("config.resetConfirmAction") }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="btn btn-secondary" type="button" :disabled="saving" @click="resetToDefaults">
|
||||
{{ t("config.resetDefaults") }}
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="saving" @click="save">
|
||||
{{ t("common.save") }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<div class="panel form-card">
|
||||
<h2 class="block-title">{{ t("config.serverSection") }}</h2>
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
<span>{{ t("config.serverAddr") }}</span>
|
||||
<input v-model="form.serverAddr" placeholder="127.0.0.1"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.serverPort") }}</span>
|
||||
<input v-model.number="form.serverPort" type="number" min="1" max="65535"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.token") }}</span>
|
||||
<input
|
||||
v-model="form.token"
|
||||
type="password"
|
||||
autocomplete="off"
|
||||
:placeholder="t('config.tokenHint')"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.user") }}</span>
|
||||
<input v-model="form.user" :placeholder="t('config.userHint')"/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel form-card">
|
||||
<h2 class="block-title">{{ t("config.transportSection") }}</h2>
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
<span>{{ t("config.protocol") }}</span>
|
||||
<select v-model="form.protocol">
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="websocket">WebSocket</option>
|
||||
<option value="quic">QUIC</option>
|
||||
<option value="kcp">KCP</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.poolCount") }}</span>
|
||||
<input v-model.number="form.poolCount" type="number" min="0" max="100"/>
|
||||
</label>
|
||||
<label class="switch-row span-2">
|
||||
<span class="switch-label">{{ t("config.tcpMux") }}</span>
|
||||
<input v-model="form.tcpMux" type="checkbox" :disabled="form.protocol === 'quic'"/>
|
||||
</label>
|
||||
<label class="switch-row span-2">
|
||||
<span class="switch-label">{{ t("config.tlsEnable") }}</span>
|
||||
<input v-model="form.tls.enable" type="checkbox"/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel form-card">
|
||||
<button class="advanced-toggle" type="button" @click="showAdvanced = !showAdvanced">
|
||||
{{ showAdvanced ? t("config.hideAdvanced") : t("config.showAdvanced") }}
|
||||
</button>
|
||||
|
||||
<div v-if="showAdvanced" class="advanced">
|
||||
<div class="grid">
|
||||
<label v-if="showMuxKeepalive" class="field">
|
||||
<span>{{ t("config.tcpMuxKeepalive") }}</span>
|
||||
<input
|
||||
v-model.number="form.tcpMuxKeepaliveInterval"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3600"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.heartbeatInterval") }}</span>
|
||||
<input
|
||||
v-model="heartbeatIntervalInput"
|
||||
type="number"
|
||||
min="0"
|
||||
max="3600"
|
||||
:placeholder="t('config.optionalEmpty')"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.heartbeatTimeout") }}</span>
|
||||
<input
|
||||
v-model="heartbeatTimeoutInput"
|
||||
type="number"
|
||||
min="0"
|
||||
max="7200"
|
||||
:placeholder="t('config.optionalEmpty')"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.udpPacketSize") }}</span>
|
||||
<input v-model.number="form.udpPacketSize" type="number" min="512" max="65535"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.tlsServerName") }}</span>
|
||||
<input v-model="form.tls.serverName"/>
|
||||
</label>
|
||||
<PathField
|
||||
v-model="form.tls.trustedCaFile"
|
||||
:label="t('config.tlsTrustedCa')"
|
||||
:placeholder="t('config.pathHint')"
|
||||
/>
|
||||
<PathField
|
||||
v-model="form.tls.certFile"
|
||||
:label="t('config.tlsCert')"
|
||||
:placeholder="t('config.pathHint')"
|
||||
/>
|
||||
<PathField
|
||||
v-model="form.tls.keyFile"
|
||||
:label="t('config.tlsKey')"
|
||||
:placeholder="t('config.pathHint')"
|
||||
/>
|
||||
<label class="switch-row span-2">
|
||||
<span class="switch-label">{{ t("config.tlsDisableFirstByte") }}</span>
|
||||
<input v-model="form.tls.disableCustomTlsFirstByte" type="checkbox"/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="showQuic" class="grid quic-grid">
|
||||
<label class="field">
|
||||
<span>{{ t("config.quicKeepalive") }}</span>
|
||||
<input
|
||||
v-model.number="form.quic.keepalivePeriod"
|
||||
type="number"
|
||||
min="1"
|
||||
max="600"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.quicIdle") }}</span>
|
||||
<input
|
||||
v-model.number="form.quic.maxIdleTimeout"
|
||||
type="number"
|
||||
min="1"
|
||||
max="3600"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("config.quicStreams") }}</span>
|
||||
<input
|
||||
v-model.number="form.quic.maxIncomingStreams"
|
||||
type="number"
|
||||
min="1"
|
||||
max="1000000"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--muted) 10%, transparent);
|
||||
border-color: color-mix(in srgb, var(--muted) 35%, var(--line));
|
||||
}
|
||||
|
||||
.form-card {
|
||||
padding: 1.15rem 1.25rem 1.35rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field > span,
|
||||
.switch-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
min-height: 2.4rem;
|
||||
}
|
||||
|
||||
.switch-row input[type="checkbox"] {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
accent-color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.advanced-toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.advanced {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
.quic-grid {
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,222 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, onUnmounted, ref} from "vue";
|
||||
import {useRouter} from "vue-router";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {getStatus, startClient, stopClient, type ClientStatus} from "@/api/desktop";
|
||||
import type {MessageSchema} from "@/i18n";
|
||||
|
||||
const router = useRouter();
|
||||
const {t} = useI18n<{ message: MessageSchema }>();
|
||||
const status = ref<ClientStatus>({running: false, runningSecs: 0, version: "2.1.0-SNAPSHOT"});
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const title = computed(() =>
|
||||
status.value.running ? t("launch.running") : t("launch.stopped"),
|
||||
);
|
||||
const runningLabel = computed(() => {
|
||||
const s = status.value.runningSecs;
|
||||
if (s < 60) return t("launch.seconds", {n: s});
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return t("launch.minutesSecs", {m, s: r});
|
||||
});
|
||||
|
||||
async function refresh(opts?: { clearError?: boolean }) {
|
||||
try {
|
||||
status.value = await getStatus();
|
||||
if (opts?.clearError) error.value = "";
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
busy.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
status.value = status.value.running ? await stopClient() : await startClient();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
await refresh({clearError: false});
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refresh({clearError: true});
|
||||
timer = setInterval(() => void refresh({clearError: false}), 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<h1 class="page-title">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3c4 2 6 6 6 10l-3 1-2 4-2-4-3-1c0-4 2-8 6-10z"/>
|
||||
<path d="M9 14l-3 5M15 14l3 5"/>
|
||||
</svg>
|
||||
{{ t("launch.title") }}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="panel launch-card">
|
||||
<div class="hero">
|
||||
<div class="orb">
|
||||
<div class="blob b1"/>
|
||||
<div class="blob b2"/>
|
||||
<div class="orb-core">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3c4 2 6 6 6 10l-3 1-2 4-2-4-3-1c0-4 2-8 6-10z"/>
|
||||
<path d="M9 14l-3 5M15 14l3 5"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-block">
|
||||
<div class="status-line">
|
||||
<span class="dot" :class="{ on: status.running }"/>
|
||||
<strong>{{ title }}</strong>
|
||||
</div>
|
||||
<div class="meta">
|
||||
{{ t("launch.runningTime") }} {{ status.running ? runningLabel : "—" }}
|
||||
<button class="btn btn-ghost" type="button" @click="router.push('/logger')">
|
||||
{{ t("launch.viewLog") }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
<button
|
||||
class="btn btn-primary stop-btn"
|
||||
type="button"
|
||||
:disabled="busy"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ status.running ? t("launch.stop") : t("launch.start") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.launch-card {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.blob {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(2px);
|
||||
background: var(--blob-a);
|
||||
}
|
||||
|
||||
.b1 {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
transform: translate(-18px, 12px);
|
||||
}
|
||||
|
||||
.b2 {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
background: var(--blob-b);
|
||||
transform: translate(28px, -16px);
|
||||
}
|
||||
|
||||
.orb-core {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 112px;
|
||||
height: 112px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 30px rgba(59, 130, 246, 0.18);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.orb-core svg {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.status-block {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
border-radius: 50%;
|
||||
background: #cbd5e1;
|
||||
box-shadow: inset 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
.dot.on {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: 0.55rem;
|
||||
color: var(--accent);
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stop-btn {
|
||||
margin-top: 1.4rem;
|
||||
min-width: 220px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,304 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {clearLogs, getLogs} from "@/api/desktop";
|
||||
import type {MessageSchema} from "@/i18n";
|
||||
|
||||
const {t} = useI18n<{ message: MessageSchema }>();
|
||||
const tab = ref<"app" | "client">("app");
|
||||
const query = ref("");
|
||||
const queryDebounced = ref("");
|
||||
const lines = ref<string[]>([]);
|
||||
const autoScroll = ref(true);
|
||||
const scroller = ref<HTMLElement | null>(null);
|
||||
|
||||
let logsRev = 0;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let queryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let refreshInFlight = false;
|
||||
let scrollPending = false;
|
||||
|
||||
function isOrbienLine(line: string) {
|
||||
return line.startsWith("[orbien]") || line.startsWith("[orbien:err]");
|
||||
}
|
||||
|
||||
function levelClass(line: string): string {
|
||||
if (line.startsWith("[error]") || line.startsWith("[orbien:err]")) return "lvl-error";
|
||||
if (line.startsWith("[warn]")) return "lvl-warn";
|
||||
if (line.startsWith("[info]") || line.startsWith("[orbien]")) return "lvl-info";
|
||||
if (line.startsWith("[debug]")) return "lvl-debug";
|
||||
|
||||
const head = line.slice(0, 160).toLowerCase();
|
||||
if (head.includes("error")) return "lvl-error";
|
||||
if (head.includes("warn")) return "lvl-warn";
|
||||
if (head.includes("info")) return "lvl-info";
|
||||
if (head.includes("debug")) return "lvl-debug";
|
||||
return "lvl-default";
|
||||
}
|
||||
|
||||
interface DisplayLine {
|
||||
text: string;
|
||||
cls: string;
|
||||
}
|
||||
|
||||
const displayLines = computed<DisplayLine[]>(() => {
|
||||
const wantOrbien = tab.value === "client";
|
||||
const q = queryDebounced.value;
|
||||
const out: DisplayLine[] = [];
|
||||
for (const line of lines.value) {
|
||||
if (isOrbienLine(line) !== wantOrbien) continue;
|
||||
if (q && !line.toLowerCase().includes(q)) continue;
|
||||
out.push({text: line, cls: levelClass(line)});
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
if (refreshInFlight) return;
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
refreshInFlight = true;
|
||||
try {
|
||||
const snap = await getLogs(logsRev);
|
||||
logsRev = snap.rev;
|
||||
if (snap.lines) {
|
||||
lines.value = snap.lines;
|
||||
}
|
||||
} catch {
|
||||
logsRev = 0;
|
||||
lines.value = ["[warn] Tauri IPC unavailable — open via `npm run tauri dev`"];
|
||||
} finally {
|
||||
refreshInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClear() {
|
||||
try {
|
||||
await clearLogs();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
logsRev = 0;
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function scheduleScroll() {
|
||||
if (!autoScroll.value || scrollPending) return;
|
||||
scrollPending = true;
|
||||
requestAnimationFrame(() => {
|
||||
scrollPending = false;
|
||||
if (!autoScroll.value || !scroller.value) return;
|
||||
scroller.value.scrollTop = scroller.value.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
watch(query, (v) => {
|
||||
if (queryTimer) clearTimeout(queryTimer);
|
||||
queryTimer = setTimeout(() => {
|
||||
queryDebounced.value = v.trim().toLowerCase();
|
||||
}, 150);
|
||||
});
|
||||
|
||||
watch([displayLines, autoScroll], async () => {
|
||||
if (!autoScroll.value) return;
|
||||
await nextTick();
|
||||
scheduleScroll();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
timer = setInterval(() => void refresh(), 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
if (queryTimer) clearTimeout(queryTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page logger-page">
|
||||
<header class="page-head">
|
||||
<h1 class="page-title">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 3h7l3 3v15H7z"/>
|
||||
<path d="M14 3v4h4M9 12h6M9 16h6"/>
|
||||
</svg>
|
||||
{{ t("logger.title") }}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
<button type="button" :class="{ active: tab === 'app' }" @click="tab = 'app'">
|
||||
{{ t("logger.appLog") }}
|
||||
</button>
|
||||
<button type="button" :class="{ active: tab === 'client' }" @click="tab = 'client'">
|
||||
{{ t("logger.orbienLog") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel console">
|
||||
<div class="toolbar">
|
||||
<input v-model="query" type="text" :placeholder="t('common.searchLogs')"/>
|
||||
<label class="auto">
|
||||
<input v-model="autoScroll" type="checkbox"/>
|
||||
{{ t("common.autoScroll") }}
|
||||
</label>
|
||||
<button class="btn btn-ghost" type="button" @click="onClear">
|
||||
{{ t("common.clear") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div ref="scroller" class="log-body">
|
||||
<div class="log-content">
|
||||
<div v-if="!displayLines.length" class="muted">{{ t("common.emptyLogs") }}</div>
|
||||
<div
|
||||
v-for="(row, i) in displayLines"
|
||||
:key="i"
|
||||
class="log-line"
|
||||
:class="row.cls"
|
||||
>
|
||||
{{ row.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logger-page {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logger-page .page-head {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 1.25rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.tabs button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
padding: 0.45rem 0.1rem 0.7rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tabs button.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.console {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #0f172a;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.toolbar input[type="text"] {
|
||||
flex: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #e8e8f0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.45rem 0.7rem;
|
||||
}
|
||||
|
||||
.auto {
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.log-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
padding: 0.9rem 1rem 1.2rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
font-family: "SF Mono", Menlo, Monaco, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
||||
ui-monospace, monospace;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 1.55em;
|
||||
}
|
||||
|
||||
.lvl-error {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.lvl-warn {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.lvl-info {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.lvl-debug {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.lvl-default {
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.log-body::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.log-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.log-body::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.log-body::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.55);
|
||||
background-clip: padding-box;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
</style>
|
||||
@@ -1,713 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {computed, onMounted, reactive, ref, watch} from "vue";
|
||||
import {useI18n} from "vue-i18n";
|
||||
import {
|
||||
defaultProxyForm,
|
||||
defaultProxyPlugin,
|
||||
defaultProxyTransport,
|
||||
getConfig,
|
||||
joinList,
|
||||
listProxies,
|
||||
normalizeProxyFromServer,
|
||||
saveProxies,
|
||||
splitList,
|
||||
type ProxyConfig,
|
||||
type ProxyItem,
|
||||
} from "@/api/desktop";
|
||||
import PathField from "@/components/PathField.vue";
|
||||
import type {MessageSchema} from "@/i18n";
|
||||
|
||||
const {t} = useI18n<{ message: MessageSchema }>();
|
||||
|
||||
const proxies = ref<ProxyItem[]>([]);
|
||||
const draft = ref<ProxyConfig[]>([]);
|
||||
const error = ref("");
|
||||
const saving = ref(false);
|
||||
const editorOpen = ref(false);
|
||||
const editingIndex = ref<number | null>(null);
|
||||
const showAdvanced = ref(false);
|
||||
const copiedName = ref("");
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const form = reactive(defaultProxyForm());
|
||||
const customDomainsText = ref("");
|
||||
const locationsText = ref("");
|
||||
const httpsMode = ref<"passthrough" | "https2http">("passthrough");
|
||||
|
||||
const isPortProxy = computed(() => form.proxyType === "tcp" || form.proxyType === "udp");
|
||||
const isHttp = computed(() => form.proxyType === "http");
|
||||
const isHttps = computed(() => form.proxyType === "https");
|
||||
const isVhost = computed(() => isHttp.value || isHttps.value);
|
||||
const useHttps2Http = computed(() => isHttps.value && httpsMode.value === "https2http");
|
||||
const showLocalDial = computed(() => !useHttps2Http.value);
|
||||
const showBandwidthMode = computed(() => !!form.transport.bandwidthLimit.trim());
|
||||
|
||||
watch(
|
||||
() => form.proxyType,
|
||||
(ty, prev) => {
|
||||
if (!editorOpen.value || ty === prev) return;
|
||||
if (ty === "tcp" || ty === "udp") {
|
||||
httpsMode.value = "passthrough";
|
||||
form.remotePort = ty === "udp" ? 7001 : 6000;
|
||||
form.localPort = ty === "udp" ? 12001 : 8080;
|
||||
form.localIp = form.localIp || "127.0.0.1";
|
||||
} else {
|
||||
form.remotePort = 0;
|
||||
if (ty === "http") {
|
||||
httpsMode.value = "passthrough";
|
||||
form.localPort = form.localPort || 80;
|
||||
}
|
||||
if (ty === "https") {
|
||||
if (httpsMode.value === "passthrough") {
|
||||
form.localPort = form.localPort && form.localPort !== 80 ? form.localPort : 443;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(httpsMode, (mode) => {
|
||||
if (!editorOpen.value || !isHttps.value) return;
|
||||
if (mode === "https2http") {
|
||||
form.plugin = {...defaultProxyPlugin(), ...(form.plugin ?? {})};
|
||||
form.plugin.type = "https2http";
|
||||
if (!form.plugin.localAddr) form.plugin.localAddr = "127.0.0.1:80";
|
||||
} else {
|
||||
form.plugin = null;
|
||||
if (!form.localPort) form.localPort = 443;
|
||||
}
|
||||
});
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [items, cfg] = await Promise.all([listProxies(), getConfig()]);
|
||||
draft.value = (cfg.proxies ?? []).map((p) => normalizeProxyFromServer(p));
|
||||
proxies.value = items;
|
||||
error.value = "";
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
function hasAdvancedValues(p: ProxyConfig, pluginMode: boolean): boolean {
|
||||
return !!(
|
||||
p.transport.bandwidthLimit ||
|
||||
p.transport.proxyProtocolVersion ||
|
||||
(p.proxyType === "http" && (p.locations.length || p.hostHeaderRewrite)) ||
|
||||
(p.localIp && p.localIp !== "127.0.0.1") ||
|
||||
(pluginMode &&
|
||||
p.plugin &&
|
||||
(p.plugin.crtPath || p.plugin.keyPath || p.plugin.hostHeaderRewrite))
|
||||
);
|
||||
}
|
||||
|
||||
function assignForm(src: ProxyConfig) {
|
||||
const p = normalizeProxyFromServer(src);
|
||||
form.name = p.name;
|
||||
form.proxyType = p.proxyType;
|
||||
form.localIp = p.localIp || "127.0.0.1";
|
||||
form.localPort = p.localPort;
|
||||
form.remotePort = p.remotePort;
|
||||
form.customDomains = [...p.customDomains];
|
||||
form.subdomain = p.subdomain;
|
||||
form.locations = [...p.locations];
|
||||
form.httpUser = "";
|
||||
form.httpPassword = "";
|
||||
form.hostHeaderRewrite = p.hostHeaderRewrite;
|
||||
form.routeByHttpUser = "";
|
||||
form.transport = {...defaultProxyTransport(), ...p.transport};
|
||||
form.plugin = p.plugin ? {...defaultProxyPlugin(), ...p.plugin} : null;
|
||||
customDomainsText.value = joinList(p.customDomains);
|
||||
locationsText.value = joinList(p.locations);
|
||||
const pluginOn = !!(p.plugin && p.plugin.type === "https2http");
|
||||
httpsMode.value = pluginOn ? "https2http" : "passthrough";
|
||||
showAdvanced.value = hasAdvancedValues(p, pluginOn);
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editingIndex.value = null;
|
||||
assignForm(defaultProxyForm("tcp"));
|
||||
showAdvanced.value = false;
|
||||
editorOpen.value = true;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function openEdit(index: number) {
|
||||
const p = draft.value[index];
|
||||
if (!p) return;
|
||||
editingIndex.value = index;
|
||||
assignForm(p);
|
||||
editorOpen.value = true;
|
||||
error.value = "";
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorOpen.value = false;
|
||||
editingIndex.value = null;
|
||||
}
|
||||
|
||||
function buildEntry(): ProxyConfig | null {
|
||||
const name = form.name.trim();
|
||||
if (!name) {
|
||||
error.value = t("proxy.nameRequired");
|
||||
return null;
|
||||
}
|
||||
|
||||
const ty = form.proxyType || "tcp";
|
||||
const pluginOn = ty === "https" && httpsMode.value === "https2http";
|
||||
|
||||
const entry = normalizeProxyFromServer({
|
||||
...form,
|
||||
name,
|
||||
proxyType: ty,
|
||||
customDomains: splitList(customDomainsText.value),
|
||||
locations: ty === "http" ? splitList(locationsText.value) : [],
|
||||
localIp: form.localIp.trim() || "127.0.0.1",
|
||||
subdomain: form.subdomain.trim(),
|
||||
httpUser: "",
|
||||
httpPassword: "",
|
||||
hostHeaderRewrite: ty === "http" ? form.hostHeaderRewrite.trim() : "",
|
||||
routeByHttpUser: "",
|
||||
transport: {
|
||||
bandwidthLimit: form.transport.bandwidthLimit.trim(),
|
||||
bandwidthLimitMode: form.transport.bandwidthLimitMode || "client",
|
||||
proxyProtocolVersion: pluginOn ? "" : form.transport.proxyProtocolVersion,
|
||||
},
|
||||
plugin: pluginOn
|
||||
? {
|
||||
type: "https2http",
|
||||
localAddr: (form.plugin?.localAddr || "").trim(),
|
||||
crtPath: (form.plugin?.crtPath || "").trim(),
|
||||
keyPath: (form.plugin?.keyPath || "").trim(),
|
||||
hostHeaderRewrite: (form.plugin?.hostHeaderRewrite || "").trim(),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
|
||||
if (ty === "tcp" || ty === "udp") {
|
||||
if (!entry.remotePort) {
|
||||
error.value = t("proxy.remotePortRequired");
|
||||
return null;
|
||||
}
|
||||
if (!entry.localPort) {
|
||||
error.value = t("proxy.localPortRequired");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (ty === "http" || ty === "https") {
|
||||
if (!entry.customDomains.length && !entry.subdomain) {
|
||||
error.value = t("proxy.domainRequired");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (pluginOn) {
|
||||
if (!entry.plugin?.localAddr) {
|
||||
error.value = t("proxy.pluginLocalAddrRequired");
|
||||
return null;
|
||||
}
|
||||
} else if ((ty === "http" || ty === "https") && !entry.localPort) {
|
||||
error.value = t("proxy.localPortRequired");
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function persist(next: ProxyConfig[]) {
|
||||
saving.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const result = await saveProxies(next);
|
||||
draft.value = next.map((p) => normalizeProxyFromServer(p));
|
||||
proxies.value = result.proxies;
|
||||
closeEditor();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEditor() {
|
||||
const entry = buildEntry();
|
||||
if (!entry) return;
|
||||
const next = [...draft.value];
|
||||
const idx = editingIndex.value;
|
||||
if (idx === null) {
|
||||
if (next.some((p) => p.name === entry.name)) {
|
||||
error.value = t("proxy.nameExists");
|
||||
return;
|
||||
}
|
||||
next.push(entry);
|
||||
} else {
|
||||
if (next.some((p, i) => i !== idx && p.name === entry.name)) {
|
||||
error.value = t("proxy.nameExists");
|
||||
return;
|
||||
}
|
||||
next[idx] = entry;
|
||||
}
|
||||
await persist(next);
|
||||
}
|
||||
|
||||
async function removeAt(index: number) {
|
||||
const next = draft.value.filter((_, i) => i !== index);
|
||||
await persist(next);
|
||||
}
|
||||
|
||||
async function copyAddress(p: ProxyItem) {
|
||||
const text = (p.copyValue || "").trim();
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
copiedName.value = p.name;
|
||||
if (copiedTimer) clearTimeout(copiedTimer);
|
||||
copiedTimer = setTimeout(() => {
|
||||
copiedName.value = "";
|
||||
copiedTimer = null;
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refresh();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<header class="page-head">
|
||||
<h1 class="page-title">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 18h10a4 4 0 0 0 .3-8 5.5 5.5 0 0 0-10.6 1.5A3.5 3.5 0 0 0 7 18z"/>
|
||||
</svg>
|
||||
{{ t("proxy.title") }}
|
||||
</h1>
|
||||
<button
|
||||
class="btn btn-icon"
|
||||
type="button"
|
||||
:title="t('common.addProxy')"
|
||||
@click="openAdd"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14"/>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="err">{{ error }}</p>
|
||||
|
||||
<div v-if="editorOpen" class="panel editor">
|
||||
<div class="editor-head">
|
||||
<div class="editor-title">
|
||||
{{ editingIndex === null ? t("proxy.addTitle") : t("proxy.editTitle") }}
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button class="btn btn-ghost" type="button" :disabled="saving" @click="closeEditor">
|
||||
{{ t("common.cancel") }}
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" :disabled="saving" @click="submitEditor">
|
||||
{{ t("common.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<label class="field">
|
||||
<span>{{ t("proxy.name") }}</span>
|
||||
<input v-model="form.name" :placeholder="t('proxy.nameHint')"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("proxy.type") }}</span>
|
||||
<select v-model="form.proxyType">
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- TCP / UDP -->
|
||||
<template v-if="isPortProxy">
|
||||
<label class="field">
|
||||
<span>{{ t("proxy.localPort") }}</span>
|
||||
<input v-model.number="form.localPort" type="number" min="1" max="65535"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("proxy.remotePort") }}</span>
|
||||
<input v-model.number="form.remotePort" type="number" min="1" max="65535"/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-if="isVhost">
|
||||
<label v-if="isHttps" class="field span-2">
|
||||
<span>{{ t("proxy.httpsMode") }}</span>
|
||||
<select v-model="httpsMode">
|
||||
<option value="passthrough">{{ t("proxy.httpsModePassthrough") }}</option>
|
||||
<option value="https2http">{{ t("proxy.httpsModePlugin") }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label v-if="showLocalDial" class="field">
|
||||
<span>{{ t("proxy.localPort") }}</span>
|
||||
<input v-model.number="form.localPort" type="number" min="1" max="65535"/>
|
||||
</label>
|
||||
<label v-if="useHttps2Http && form.plugin" class="field">
|
||||
<span>{{ t("proxy.pluginLocalAddr") }}</span>
|
||||
<input
|
||||
v-model="form.plugin.localAddr"
|
||||
:placeholder="t('proxy.pluginLocalAddrHint')"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("proxy.subdomain") }}</span>
|
||||
<input v-model="form.subdomain" :placeholder="t('proxy.subdomainHint')"/>
|
||||
</label>
|
||||
|
||||
<label class="field span-2">
|
||||
<span>{{ t("proxy.customDomains") }}</span>
|
||||
<input
|
||||
v-model="customDomainsText"
|
||||
:placeholder="t('proxy.customDomainsHint')"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button class="advanced-toggle" type="button" @click="showAdvanced = !showAdvanced">
|
||||
{{ showAdvanced ? t("proxy.hideAdvanced") : t("proxy.showAdvanced") }}
|
||||
</button>
|
||||
|
||||
<div v-if="showAdvanced" class="advanced grid">
|
||||
<label v-if="showLocalDial" class="field">
|
||||
<span>{{ t("proxy.localIp") }}</span>
|
||||
<input v-model="form.localIp" placeholder="127.0.0.1"/>
|
||||
</label>
|
||||
|
||||
<template v-if="isHttp">
|
||||
<label class="field">
|
||||
<span>{{ t("proxy.locations") }}</span>
|
||||
<input v-model="locationsText" :placeholder="t('proxy.locationsHint')"/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>{{ t("proxy.hostHeaderRewrite") }}</span>
|
||||
<input
|
||||
v-model="form.hostHeaderRewrite"
|
||||
:placeholder="t('proxy.hostHeaderRewriteHint')"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-if="useHttps2Http && form.plugin">
|
||||
<PathField
|
||||
v-model="form.plugin.crtPath"
|
||||
:label="t('proxy.pluginCrt')"
|
||||
:placeholder="t('proxy.pathHint')"
|
||||
/>
|
||||
<PathField
|
||||
v-model="form.plugin.keyPath"
|
||||
:label="t('proxy.pluginKey')"
|
||||
:placeholder="t('proxy.pathHint')"
|
||||
/>
|
||||
<label class="field span-2">
|
||||
<span>{{ t("proxy.hostHeaderRewrite") }}</span>
|
||||
<input
|
||||
v-model="form.plugin.hostHeaderRewrite"
|
||||
:placeholder="t('proxy.pluginHostRewriteHint')"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t("proxy.bandwidthLimit") }}</span>
|
||||
<input
|
||||
v-model="form.transport.bandwidthLimit"
|
||||
:placeholder="t('proxy.bandwidthLimitHint')"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="showBandwidthMode" class="field">
|
||||
<span>{{ t("proxy.bandwidthMode") }}</span>
|
||||
<select v-model="form.transport.bandwidthLimitMode">
|
||||
<option value="client">client</option>
|
||||
<option value="server">server</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="showLocalDial" class="field">
|
||||
<span>{{ t("proxy.proxyProtocol") }}</span>
|
||||
<select v-model="form.transport.proxyProtocolVersion">
|
||||
<option value="">{{ t("proxy.proxyProtocolOff") }}</option>
|
||||
<option value="v1">v1</option>
|
||||
<option value="v2">v2</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list">
|
||||
<article v-for="(p, index) in proxies" :key="p.name" class="panel proxy-card">
|
||||
<div class="card-top">
|
||||
<div class="name-row">
|
||||
<div class="name">{{ p.name }}</div>
|
||||
<span class="tag">{{ p.proxyType }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" type="button" @click="openEdit(index)">
|
||||
{{ t("common.modify") }}
|
||||
</button>
|
||||
<button class="btn btn-ghost danger" type="button" @click="removeAt(index)">
|
||||
{{ t("common.delete") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="meta">
|
||||
<div class="meta-row">
|
||||
<dt>{{ t("proxy.inner") }}</dt>
|
||||
<dd>{{ p.local }}</dd>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<dt>{{ t("proxy.remote") }}</dt>
|
||||
<dd>
|
||||
<span class="remote-text" :title="p.remote">{{ p.remote }}</span>
|
||||
<button
|
||||
v-if="p.copyValue"
|
||||
class="copy-btn"
|
||||
type="button"
|
||||
:title="copiedName === p.name ? t('proxy.copied') : t('proxy.copy')"
|
||||
@click="copyAddress(p)"
|
||||
>
|
||||
{{ copiedName === p.name ? t("proxy.copied") : t("proxy.copy") }}
|
||||
</button>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor {
|
||||
padding: 1.15rem 1.25rem 1.35rem;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.span-2 {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field > span {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.8rem;
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
outline: none;
|
||||
border-color: rgba(59, 130, 246, 0.55);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.advanced-toggle {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.advanced-toggle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.advanced {
|
||||
padding-top: 0.15rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.85rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.proxy-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem 1.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
color: var(--text);
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tag {
|
||||
flex-shrink: 0;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.12rem 0.55rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.5rem minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.meta-row dt {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.meta-row dd {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.remote-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.2rem 0.5rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.list,
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type {DefineComponent} from "vue";
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import {defineConfig} from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import {fileURLToPath, URL} from "node:url";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || "127.0.0.1",
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||