refactor: 删除Tauri桌面模块,后续采用基于slint的原生应用

This commit is contained in:
lxien
2026-08-13 21:27:08 +08:00
parent 4e6e612ee8
commit a77b6ee09e
48 changed files with 0 additions and 11759 deletions
-9
View File
@@ -1,9 +0,0 @@
node_modules/
dist/
dist-ssr/
*.local
*.log
.DS_Store
.idea/
.vscode/*
!.vscode/extensions.json
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
-48
View File
@@ -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"
```
-13
View File
@@ -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>
-1864
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -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"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

-3
View File
@@ -1,3 +0,0 @@
# Cargo / Tauri 生成物
/target/
/gen/schemas
-4899
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -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"
View File
-5
View File
@@ -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"
]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

-661
View File
@@ -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)
}
-469
View File
@@ -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");
}
-5
View File
@@ -1,5 +0,0 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
orbien_desktop_lib::run()
}
-208
View File
@@ -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))
}
}
-197
View File
@@ -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(())
}
-51
View File
@@ -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
}
}
}
-33
View File
@@ -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>
-132
View File
@@ -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,
});
}
-178
View File
@@ -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,
};
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 205 KiB

-216
View File
@@ -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>
-100
View File
@@ -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>
-62
View File
@@ -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";
-17
View File
@@ -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);
}
-180
View File
@@ -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;
-179
View File
@@ -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 为 bindPortQUIC/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 在拨号后套一层 TLSQUIC 本身已加密",
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;
-174
View File
@@ -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
}
}
-7
View File
@@ -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");
-19
View File
@@ -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;
-169
View File
@@ -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;
}
-480
View File
@@ -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>
-222
View File
@@ -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>
-304
View File
@@ -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>
-713
View File
@@ -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>
-7
View File
@@ -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;
}
-25
View File
@@ -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" }]
}
-10
View File
@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
-31
View File
@@ -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/**"],
},
},
}));