Compare commits

..

3 Commits

Author SHA1 Message Date
YoVinchen 9620a8c769 feat(terminal): add Tabby terminal support on macOS, Windows, and Linux
Add Tabby (https://github.com/Eugeny/tabby) as a preferred terminal option
across all three platforms:

- macOS: launch via `open -na Tabby --args run` with login shell
- Windows: search multiple executable candidates (PATH, LOCALAPPDATA,
  ProgramFiles) and launch via `tabby run cmd /K`
- Linux: add `tabby` and `tabby-terminal` to the auto-detection list

Also includes:
- Session manager integration with login-shell command wrapping
- Terminal selector UI entries for all platforms
- i18n labels (zh/en/ja)
- Unit tests for Tabby arg building (with/without cwd)

Closes https://github.com/farion1231/cc-switch/issues/1708
2026-03-30 00:43:55 +08:00
Dex Miller 67e074c0a7 refactor(proxy): transparent header forwarding via hyper client (#1714)
* style(frontend): reformat provider forms, constants and hooks

Apply prettier formatting across 5 frontend files. No logic changes.

Changed files:
- AddProviderDialog.tsx: reformat generic type annotation and callback
- ClaudeFormFields.tsx: consolidate multi-line useState and Collapsible props
- CodexConfigSections.tsx: expand single-line React imports to multi-line,
  collapse removeCodexTopLevelField() call
- constants.ts: merge TemplateType into single line
- useSkills.ts: expand single-line TanStack Query imports to multi-line,
  reformat uninstallSkill mutationFn chain

* deps(proxy): add hyper ecosystem crates and manual decompression libs

reqwest internally normalizes all header names to lowercase and does not
preserve insertion order, causing proxied requests to differ from the
original client requests. To achieve transparent header forwarding with
original casing and order, introduce lower-level hyper HTTP client libs.

New dependencies:
- hyper-util 0.1: TokioExecutor + legacy Client with
  preserve_header_case support for HTTP/1.1
- hyper-rustls 0.27: rustls-based TLS connector for hyper
- http 1 / http-body 1 / http-body-util 0.1: HTTP type crates for
  hyper 1.x request/response construction
- flate2 1: manual gzip/deflate decompression (replaces reqwest auto)
- brotli 7: manual brotli decompression

Changed dependencies:
- serde_json: enable preserve_order feature to keep JSON field order
- reqwest: drop gzip feature to prevent reqwest from overriding the
  client's original accept-encoding header

* refactor(proxy): use hyper client for header-case preserving forwarding

Previously the proxy used reqwest for all upstream requests. reqwest
normalizes header names to lowercase and reorders them internally,
making proxied requests distinguishable from direct CLI requests.
Some upstream providers are sensitive to these differences.

This commit replaces reqwest with a hyper-based HTTP client on the
default (non-proxy) path, achieving wire-level header fidelity:

Server layer (server.rs):
- Replace axum::serve with a manual hyper HTTP/1.1 accept loop
- Enable preserve_header_case(true) so incoming header casing is
  captured in a HeaderCaseMap extension on each request
- Bridge hyper requests to axum Router via tower::Service

New hyper client module (hyper_client.rs):
- Lazy-initialized hyper-util Client with preserve_header_case
- ProxyResponse enum wrapping both hyper::Response and reqwest::Response
  behind a unified interface (status, headers, bytes, bytes_stream)
- send_request() builds requests with ordered HeaderMap + case map

Request handlers (handlers.rs):
- Switch from (HeaderMap, Json<Value>) extractors to raw
  axum::extract::Request to preserve Extensions (containing the
  HeaderCaseMap from the accept loop)
- Pass extensions through the forwarding chain

Forwarder (forwarder.rs):
- Remove HEADER_BLACKLIST array; replace with ordered header iteration
  that preserves original header sequence and casing
- Build ordered_headers by iterating client headers, skipping only
  auth/host/content-length, and inserting auth headers at the original
  authorization position to maintain order
- Handle anthropic-beta (ensure claude-code-20250219 tag) and
  anthropic-version (passthrough or default) inline during iteration
- Remove should_force_identity_encoding() — accept-encoding is now
  transparently forwarded to upstream
- Use hyper client by default; fall back to reqwest only when an
  HTTP/SOCKS5 proxy tunnel is configured

Provider adapters (adapter.rs, claude.rs, codex.rs, gemini.rs):
- Replace add_auth_headers(RequestBuilder) -> RequestBuilder with
  get_auth_headers(AuthInfo) -> Vec<(HeaderName, HeaderValue)>
- Adapters now return header pairs instead of mutating a reqwest builder
- Claude adapter: merge Anthropic/ClaudeAuth/Bearer into single branch;
  move Copilot fingerprint headers into get_auth_headers

Response processing (response_processor.rs):
- Add manual decompression (gzip/deflate/brotli via flate2 + brotli)
  for non-streaming responses, since reqwest auto-decompression is now
  disabled to allow accept-encoding passthrough
- Add compressed-SSE warning log for streaming responses
- Accept ProxyResponse instead of reqwest::Response

HTTP client (http_client.rs):
- Disable reqwest auto-decompression (.no_gzip/.no_brotli/.no_deflate)
  on both global and per-provider clients

Streaming adapters (streaming.rs, streaming_responses.rs):
- Generalize stream error type from reqwest::Error to generic E: Error

Misc:
- log_codes.rs: add SRV-005 (ACCEPT_ERR) and SRV-006 (CONN_ERR)
- stream_check.rs: reformat copilot header lines
- transform.rs: fix trailing whitespace alignment

* fix(lint): resolve 35 clippy warnings across Rust codebase

Fix all clippy warnings reported by `cargo clippy --lib`:

- codex_config.rs: fix doc_overindented_list_items (3 spaces -> 2)
- commands/copilot.rs: inline format args in 2 log::error! calls
- commands/provider.rs: inline format args in 3 map_err closures
- proxy/hyper_client.rs: inline format arg in log::debug! call
- proxy/providers/copilot_auth.rs: inline format args in 16 locations
  (log macros, format! in headers, error constructors)
- proxy/thinking_optimizer.rs: inline format args in 2 log::info! calls
- services/skill.rs: inline format args in log::debug! call
- services/webdav_sync.rs: inline format args in 6 format! calls
  (version compat messages, download limit messages)
- services/webdav_sync/archive.rs: inline format args in 2 format! calls
- session_manager/providers/opencode.rs: inline format args in
  source_path format!

All fixes use the clippy::uninlined_format_args suggestion pattern:
  format!("msg: {}", var)  ->  format!("msg: {var}")

* deps(proxy): add raw HTTP write and native TLS cert dependencies

Add crates required for the raw TCP/TLS write path that bypasses
hyper's header encoder to preserve original header name casing:

- httparse: parse raw TCP peek bytes to capture header casings
- tokio-rustls + rustls: direct TLS connections for raw write path
- webpki-roots: Mozilla CA bundle baseline
- rustls-native-certs: load system keychain CAs (trusts proxy MITM
  certificates from Clash, mitmproxy, etc.)

* fix(proxy): address code review feedback on response handling

Fixes from PR #1714 code review:

- Extract `read_decoded_body()` and `strip_entity_headers_for_rebuilt_body()`
  in response_processor to properly clean content-encoding/content-length
  headers after decompression
- Reuse `read_decoded_body()` in handlers.rs for Claude transform path,
  ensuring compressed responses are decoded before format conversion
- Make `build_proxy_url_from_config()` public so forwarder can pass proxy
  URL to the hyper raw write path
- Add `has_system_proxy_env()` utility with test coverage
- Add 50ms backoff after accept() failures in server.rs to prevent
  tight-loop CPU spin on transient socket errors

* feat(proxy): implement raw TCP/TLS write with HTTP CONNECT tunnel

Rewrite hyper_client with a two-tier strategy for header case preservation:

Primary path (raw write):
- Peek raw TCP bytes in server.rs to capture OriginalHeaderCases before
  hyper lowercases them
- Build raw HTTP/1.1 request bytes with exact original header name casing
- Write directly to TLS stream, then use WriteFilter to let hyper parse
  the response while discarding its duplicate request writes
- Support HTTP CONNECT tunneling through upstream proxies, so header case
  is preserved even when a proxy (Clash, V2Ray) is configured

Fallback path (hyper-util Client):
- Used when OriginalHeaderCases is empty or raw write fails
- Configured with title_case_headers(true) for best-effort casing

TLS improvements:
- Load native system certificates alongside webpki roots so proxy MITM
  CAs (installed in system keychain) are trusted through CONNECT tunnels

Key types added:
- OriginalHeaderCases: maps lowercase name → original wire-casing bytes
- WriteFilter<S>: AsyncRead+AsyncWrite wrapper that discards writes
- connect_via_proxy(): HTTP CONNECT tunnel establishment
- ExtensionDebugMarker: diagnostic marker for extension chain debugging

* refactor(proxy): route requests through hyper with proxy-aware forwarding

Rework forwarder request dispatch to always prefer the hyper raw write
path (header case preservation) over reqwest:

Request routing:
- HTTP/HTTPS proxy: hyper raw write through CONNECT tunnel (case preserved)
- SOCKS5 proxy: reqwest fallback (CONNECT not supported for SOCKS5)
- No proxy: hyper raw write direct connection

Header handling improvements:
- Replace host header in-place at original position instead of
  skip-and-append, preserving client's header ordering
- Preserve client's original accept-encoding for transparent passthrough;
  only force identity encoding when transform path needs decompression
- Add should_force_identity_encoding() to centralize the decision
- Remove hardcoded 'br, gzip, deflate' override that masked client values

Proxy URL resolution (priority order):
1. Provider-specific proxy config (if enabled)
2. Global proxy URL configured in CC Switch
3. Direct connection (no proxy)

* chore(proxy): remove dead code, redundant tests and debug scaffolding

- Inline should_force_identity_encoding() (was just `needs_transform`)
  and delete its 5 test cases
- Remove ExtensionDebugMarker diagnostic type
- Remove unused has_system_proxy_env() and its test
- Remove strip_entity_headers test
- Simplify hyper path: remove redundant is_socks_proxy ternary
- Update hyper_client module doc to reflect CONNECT tunnel support

* fix(proxy): block direct-connect fallback and complete CONNECT tunnel support

* feat(hooks): improve proxy requirement warnings with specific reasons

- Remove redundant OpenAI format hint toast messages
- Add detailed reason detection for proxy requirements (OpenAI Chat, OpenAI Responses, full URL mode)
- Update i18n files with new reason-specific keys

* style(*): format code with prettier

- Remove extra whitespace in http_client.rs
- Fix formatting issues in useProviderActions.ts

* fix(proxy): post-merge fixes for forward return type and clippy warnings

- Restore forward() return type to (ProxyResponse, Option<String>)
  to pass claude_api_format through to callers
- Inline format args in log::warn! macro (clippy::uninlined_format_args)
- Suppress too_many_arguments for check_claude_stream

* refactor(proxy): preserve original header wire order and add non-streaming body timeout

- Rewrite build_raw_request to emit headers in original
  client-sent sequence instead of hash-map order
- Remove unused OriginalHeaderCases::get_all method
- Add body_timeout to read_decoded_body to prevent
  requests hanging when upstream stalls after headers
2026-03-29 20:26:15 +08:00
ruokeqx b1c7fe5563 feat: add lightweight mode (#1739) 2026-03-29 18:35:23 +08:00
13 changed files with 363 additions and 15 deletions
+14
View File
@@ -0,0 +1,14 @@
#[tauri::command]
pub fn enter_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
crate::lightweight::enter_lightweight_mode(&app)
}
#[tauri::command]
pub fn exit_lightweight_mode(app: tauri::AppHandle) -> Result<(), String> {
crate::lightweight::exit_lightweight_mode(&app)
}
#[tauri::command]
pub fn is_lightweight_mode() -> bool {
crate::lightweight::is_lightweight_mode()
}
+87
View File
@@ -888,6 +888,7 @@ exec bash --norc --noprofile
"kitty" => launch_macos_open_app("kitty", &script_file, false),
"ghostty" => launch_macos_open_app("Ghostty", &script_file, true),
"wezterm" => launch_macos_open_app("WezTerm", &script_file, true),
"tabby" => launch_macos_tabby(&script_file),
_ => launch_macos_terminal_app(&script_file), // "terminal" or default
};
@@ -1005,6 +1006,39 @@ fn launch_macos_open_app(
Ok(())
}
/// macOS: Tabby
#[cfg(target_os = "macos")]
fn launch_macos_tabby(script_file: &std::path::Path) -> Result<(), String> {
use std::process::Command;
let output = Command::new("open")
.args(build_macos_tabby_args(script_file))
.output()
.map_err(|e| format!("启动 Tabby 失败: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"Tabby 启动失败 (exit code: {:?}): {}",
output.status.code(),
stderr
));
}
Ok(())
}
fn build_macos_tabby_args(script_file: &std::path::Path) -> Vec<String> {
vec![
"-na".to_string(),
"Tabby".to_string(),
"--args".to_string(),
"run".to_string(),
"bash".to_string(),
script_file.to_string_lossy().into_owned(),
]
}
/// Linux: 根据用户首选终端启动
#[cfg(target_os = "linux")]
fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
@@ -1023,6 +1057,8 @@ fn launch_linux_terminal(config_file: &std::path::Path) -> Result<(), String> {
("alacritty", vec!["-e"]),
("kitty", vec!["-e"]),
("ghostty", vec!["-e"]),
("tabby", vec!["run"]),
("tabby-terminal", vec!["run"]),
];
// Create temp script file
@@ -1148,6 +1184,7 @@ del \"%~f0\" >nul 2>&1
"PowerShell",
),
"wt" => run_windows_start_command(&["wt", "cmd", "/K", &bat_path], "Windows Terminal"),
"tabby" => run_windows_tabby_command(&bat_path),
_ => run_windows_start_command(&["cmd", "/K", &bat_path], "cmd"), // "cmd" or default
};
@@ -1164,6 +1201,56 @@ del \"%~f0\" >nul 2>&1
result
}
#[cfg(target_os = "windows")]
fn run_windows_tabby_command(bat_path: &str) -> Result<(), String> {
use std::process::Command;
let mut last_error = String::from("未找到可用的 Tabby 可执行文件");
for candidate in windows_tabby_executable_candidates() {
let result = Command::new(&candidate)
.args(["run", "cmd", "/K", bat_path])
.creation_flags(CREATE_NO_WINDOW)
.spawn();
match result {
Ok(_) => return Ok(()),
Err(e) => {
last_error = format!("执行 {} 失败: {}", candidate.display(), e);
}
}
}
Err(last_error)
}
#[cfg(target_os = "windows")]
fn windows_tabby_executable_candidates() -> Vec<std::path::PathBuf> {
let mut candidates = Vec::new();
for candidate in ["tabby", "tabby.exe", "Tabby", "Tabby.exe"] {
push_unique_path(&mut candidates, std::path::PathBuf::from(candidate));
}
if let Some(local_app_data) = std::env::var_os("LOCALAPPDATA") {
let tabby_dir = std::path::PathBuf::from(local_app_data)
.join("Programs")
.join("Tabby");
push_unique_path(&mut candidates, tabby_dir.join("Tabby.exe"));
push_unique_path(&mut candidates, tabby_dir.join("tabby.exe"));
}
for env_key in ["ProgramFiles", "ProgramFiles(x86)"] {
if let Some(base_dir) = std::env::var_os(env_key) {
let tabby_dir = std::path::PathBuf::from(base_dir).join("Tabby");
push_unique_path(&mut candidates, tabby_dir.join("Tabby.exe"));
push_unique_path(&mut candidates, tabby_dir.join("tabby.exe"));
}
}
candidates
}
/// Windows: Run a start command with common error handling
#[cfg(target_os = "windows")]
fn run_windows_start_command(args: &[&str], terminal_name: &str) -> Result<(), String> {
+2
View File
@@ -22,6 +22,7 @@ pub mod skill;
mod stream_check;
mod sync_support;
mod lightweight;
mod usage;
mod webdav_sync;
mod workspace;
@@ -47,6 +48,7 @@ pub use settings::*;
pub use skill::*;
pub use stream_check::*;
pub use lightweight::*;
pub use usage::*;
pub use webdav_sync::*;
pub use workspace::*;
+28
View File
@@ -12,6 +12,7 @@ mod error;
mod gemini_config;
mod gemini_mcp;
mod init_status;
mod lightweight;
mod mcp;
mod openclaw_config;
mod opencode_config;
@@ -204,6 +205,12 @@ pub fn run() {
log::debug!(" arg[{i}]: {}", redact_url_for_log(arg));
}
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
// Check for deep link URL in args (mainly for Windows/Linux command line)
let mut found_deeplink = false;
for arg in &args {
@@ -615,6 +622,12 @@ pub fn run() {
let urls = event.urls();
log::info!("Received {} URL(s)", urls.len());
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(&app_handle) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
for (i, url) in urls.iter().enumerate() {
let url_str = url.as_str();
log::debug!(" URL[{i}]: {}", redact_url_for_log(url_str));
@@ -1085,6 +1098,10 @@ pub fn run() {
commands::delete_daily_memory_file,
commands::search_daily_memory_files,
commands::open_workspace_directory,
// lightweight mode (for testing or low-resource environments)
commands::enter_lightweight_mode,
commands::exit_lightweight_mode,
commands::is_lightweight_mode,
]);
let app = builder
@@ -1135,6 +1152,10 @@ pub fn run() {
let _ = window.show();
let _ = window.set_focus();
tray::apply_tray_policy(app_handle, true);
} else if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
}
// 处理通过自定义 URL 协议触发的打开事件(例如 ccswitch://...
@@ -1144,6 +1165,13 @@ pub fn run() {
log::info!("RunEvent::Opened with URL: {url_str}");
if url_str.starts_with("ccswitch://") {
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app_handle)
{
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
// 解析并广播深链接事件,复用与 single_instance 相同的逻辑
match crate::deeplink::parse_deeplink_url(&url_str) {
Ok(request) => {
+90
View File
@@ -0,0 +1,90 @@
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::Manager;
static LIGHTWEIGHT_MODE: AtomicBool = AtomicBool::new(false);
pub fn enter_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_skip_taskbar(true);
}
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, false);
}
if let Some(window) = app.get_webview_window("main") {
window
.destroy()
.map_err(|e| format!("销毁主窗口失败: {e}"))?;
}
// else: already in lightweight mode or window not found, just set the flag
LIGHTWEIGHT_MODE.store(true, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("进入轻量模式");
Ok(())
}
pub fn exit_lightweight_mode(app: &tauri::AppHandle) -> Result<(), String> {
use tauri::WebviewWindowBuilder;
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
#[cfg(target_os = "windows")]
{
let _ = window.set_skip_taskbar(false);
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, true);
}
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("退出轻量模式");
return Ok(());
}
let window_config = app
.config()
.app
.windows
.iter()
.find(|w| w.label == "main")
.ok_or("主窗口配置未找到")?;
WebviewWindowBuilder::from_config(app, window_config)
.map_err(|e| format!("加载主窗口配置失败: {e}"))?
.visible(true)
.build()
.map_err(|e| format!("创建主窗口失败: {e}"))?;
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
}
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_skip_taskbar(false);
}
}
#[cfg(target_os = "macos")]
{
crate::tray::apply_tray_policy(app, true);
}
LIGHTWEIGHT_MODE.store(false, Ordering::Release);
crate::tray::refresh_tray_menu(app);
log::info!("退出轻量模式");
Ok(())
}
pub fn is_lightweight_mode() -> bool {
LIGHTWEIGHT_MODE.load(Ordering::Acquire)
}
@@ -21,6 +21,7 @@ pub fn launch_terminal(
"kitty" => launch_kitty(command, cwd),
"wezterm" => launch_wezterm(command, cwd),
"alacritty" => launch_alacritty(command, cwd),
"tabby" => launch_tabby(command, cwd),
"custom" => launch_custom(command, cwd, custom_config),
_ => Err(format!("Unsupported terminal target: {target}")),
}
@@ -211,6 +212,35 @@ fn launch_alacritty(command: &str, cwd: Option<&str>) -> Result<(), String> {
}
}
fn launch_tabby(command: &str, cwd: Option<&str>) -> Result<(), String> {
let status = Command::new("open")
.args(build_tabby_args(command, cwd))
.status()
.map_err(|e| format!("Failed to launch Tabby: {e}"))?;
if status.success() {
Ok(())
} else {
Err("Failed to launch Tabby.".to_string())
}
}
fn build_tabby_args(command: &str, cwd: Option<&str>) -> Vec<String> {
let full_command = build_shell_command(command, cwd);
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
vec![
"-na".to_string(),
"Tabby".to_string(),
"--args".to_string(),
"run".to_string(),
shell,
"-l".to_string(),
"-c".to_string(),
full_command,
]
}
fn launch_custom(
command: &str,
cwd: Option<&str>,
@@ -305,4 +335,44 @@ mod tests {
"raw:echo foo\\\\\\\\bar\\npwd\\n"
);
}
#[test]
fn tabby_uses_login_shell_for_resume_commands() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let args = build_tabby_args("claude --resume abc-123", Some("/tmp/project dir"));
assert_eq!(
args,
vec![
"-na",
"Tabby",
"--args",
"run",
&shell,
"-l",
"-c",
"cd \"/tmp/project dir\" && claude --resume abc-123",
]
);
}
#[test]
fn tabby_keeps_command_when_no_cwd_is_provided() {
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string());
let args = build_tabby_args("claude --resume abc-123", None);
assert_eq!(
args,
vec![
"-na",
"Tabby",
"--args",
"run",
&shell,
"-l",
"-c",
"claude --resume abc-123",
]
);
}
}
+3 -3
View File
@@ -264,9 +264,9 @@ pub struct AppSettings {
// ===== 终端设置 =====
/// 首选终端应用(可选,默认使用系统默认终端)
/// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal)
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
/// - macOS: "terminal" | "iterm2" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "tabby"
/// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal) | "tabby"
/// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" | "tabby"
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preferred_terminal: Option<String>,
}
+45
View File
@@ -14,6 +14,7 @@ use crate::store::AppState;
pub struct TrayTexts {
pub show_main: &'static str,
pub no_provider_hint: &'static str,
pub lightweight_mode: &'static str,
pub quit: &'static str,
pub _auto_label: &'static str,
}
@@ -24,6 +25,7 @@ impl TrayTexts {
"en" => Self {
show_main: "Open main window",
no_provider_hint: " (No providers yet, please add them from the main window)",
lightweight_mode: "Lightweight Mode",
quit: "Quit",
_auto_label: "Auto (Failover)",
},
@@ -31,12 +33,14 @@ impl TrayTexts {
show_main: "メインウィンドウを開く",
no_provider_hint:
" (プロバイダーがまだありません。メイン画面から追加してください)",
lightweight_mode: "軽量モード",
quit: "終了",
_auto_label: "自動 (フェイルオーバー)",
},
_ => Self {
show_main: "打开主界面",
no_provider_hint: " (无供应商,请在主界面添加)",
lightweight_mode: "轻量模式",
quit: "退出",
_auto_label: "自动 (故障转移)",
},
@@ -382,6 +386,18 @@ pub fn create_tray_menu(
menu_builder = menu_builder.separator();
}
let lightweight_item = CheckMenuItem::with_id(
app,
"lightweight_mode",
tray_texts.lightweight_mode,
true,
crate::lightweight::is_lightweight_mode(),
None::<&str>,
)
.map_err(|e| AppError::Message(format!("创建轻量模式菜单失败: {e}")))?;
menu_builder = menu_builder.item(&lightweight_item).separator();
// 退出菜单(分隔符已在上面的 section 循环中添加)
let quit_item = MenuItem::with_id(app, "quit", tray_texts.quit, true, None::<&str>)
.map_err(|e| AppError::Message(format!("创建退出菜单失败: {e}")))?;
@@ -393,6 +409,20 @@ pub fn create_tray_menu(
.map_err(|e| AppError::Message(format!("构建菜单失败: {e}")))
}
pub fn refresh_tray_menu(app: &tauri::AppHandle) {
use crate::store::AppState;
if let Some(state) = app.try_state::<AppState>() {
if let Ok(new_menu) = create_tray_menu(app, state.inner()) {
if let Some(tray) = app.tray_by_id("main") {
if let Err(e) = tray.set_menu(Some(new_menu)) {
log::error!("刷新托盘菜单失败: {e}");
}
}
}
}
}
#[cfg(target_os = "macos")]
pub fn apply_tray_policy(app: &tauri::AppHandle, dock_visible: bool) {
use tauri::ActivationPolicy;
@@ -430,6 +460,21 @@ pub fn handle_tray_menu_event(app: &tauri::AppHandle, event_id: &str) {
{
apply_tray_policy(app, true);
}
} else if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式重建窗口失败: {e}");
}
}
}
"lightweight_mode" => {
if crate::lightweight::is_lightweight_mode() {
if let Err(e) = crate::lightweight::exit_lightweight_mode(app) {
log::error!("退出轻量模式失败: {e}");
}
} else {
if let Err(e) = crate::lightweight::enter_lightweight_mode(app) {
log::error!("进入轻量模式失败: {e}");
}
}
}
"quit" => {
@@ -16,6 +16,7 @@ const MACOS_TERMINALS = [
{ value: "kitty", labelKey: "settings.terminal.options.macos.kitty" },
{ value: "ghostty", labelKey: "settings.terminal.options.macos.ghostty" },
{ value: "wezterm", labelKey: "settings.terminal.options.macos.wezterm" },
{ value: "tabby", labelKey: "settings.terminal.options.macos.tabby" },
] as const;
const WINDOWS_TERMINALS = [
@@ -25,6 +26,7 @@ const WINDOWS_TERMINALS = [
labelKey: "settings.terminal.options.windows.powershell",
},
{ value: "wt", labelKey: "settings.terminal.options.windows.wt" },
{ value: "tabby", labelKey: "settings.terminal.options.windows.tabby" },
] as const;
const LINUX_TERMINALS = [
@@ -40,6 +42,7 @@ const LINUX_TERMINALS = [
{ value: "alacritty", labelKey: "settings.terminal.options.linux.alacritty" },
{ value: "kitty", labelKey: "settings.terminal.options.linux.kitty" },
{ value: "ghostty", labelKey: "settings.terminal.options.linux.ghostty" },
{ value: "tabby", labelKey: "settings.terminal.options.linux.tabby" },
] as const;
// Get terminals for the current platform
+6 -3
View File
@@ -496,12 +496,14 @@
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty",
"wezterm": "WezTerm"
"wezterm": "WezTerm",
"tabby": "Tabby"
},
"windows": {
"cmd": "Command Prompt",
"powershell": "PowerShell",
"wt": "Windows Terminal"
"wt": "Windows Terminal",
"tabby": "Tabby"
},
"linux": {
"gnomeTerminal": "GNOME Terminal",
@@ -509,7 +511,8 @@
"xfce4Terminal": "Xfce4 Terminal",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
"ghostty": "Ghostty",
"tabby": "Tabby"
}
}
},
+6 -3
View File
@@ -496,12 +496,14 @@
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty",
"wezterm": "WezTerm"
"wezterm": "WezTerm",
"tabby": "Tabby"
},
"windows": {
"cmd": "コマンドプロンプト",
"powershell": "PowerShell",
"wt": "Windows Terminal"
"wt": "Windows Terminal",
"tabby": "Tabby"
},
"linux": {
"gnomeTerminal": "GNOME Terminal",
@@ -509,7 +511,8 @@
"xfce4Terminal": "Xfce4 Terminal",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
"ghostty": "Ghostty",
"tabby": "Tabby"
}
}
},
+6 -3
View File
@@ -496,12 +496,14 @@
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty",
"wezterm": "WezTerm"
"wezterm": "WezTerm",
"tabby": "Tabby"
},
"windows": {
"cmd": "命令提示符",
"powershell": "PowerShell",
"wt": "Windows Terminal"
"wt": "Windows Terminal",
"tabby": "Tabby"
},
"linux": {
"gnomeTerminal": "GNOME Terminal",
@@ -509,7 +511,8 @@
"xfce4Terminal": "Xfce4 Terminal",
"alacritty": "Alacritty",
"kitty": "Kitty",
"ghostty": "Ghostty"
"ghostty": "Ghostty",
"tabby": "Tabby"
}
}
},
+3 -3
View File
@@ -303,9 +303,9 @@ export interface Settings {
// ===== 终端设置 =====
// 首选终端应用(可选,默认使用系统默认终端)
// macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty"
// Windows: "cmd" | "powershell" | "wt"
// Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty"
// macOS: "terminal" | "iterm2" | "alacritty" | "kitty" | "ghostty" | "wezterm" | "tabby"
// Windows: "cmd" | "powershell" | "wt" | "tabby"
// Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" | "tabby"
preferredTerminal?: string;
}