mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-18 20:50:06 +02:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1fcfd5403 | |||
| 9dc9e13182 | |||
| c5a168ae0f | |||
| 168b7ac6d4 | |||
| e5910ad5cf | |||
| 202f2c852b | |||
| 5a8864654d |
@@ -620,7 +620,7 @@ jobs:
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 #v6.0.3
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@76c631d198f9ff620e15468e45f3457d50481b57 #v1.16.2
|
||||
uses: anomalyco/opencode/github@abda3515f444c4d28a98953d153c5a3e1892d3d4 #v1.17.4
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11)
|
||||
|
||||
**NEVER run any git command that modifies git history OR the working tree, in ANY repo** (wayfern, wayfern-macos, wayfern-test, donutbrowser, build/src), **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
|
||||
|
||||
---
|
||||
|
||||
# Project Guidelines
|
||||
|
||||
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.26.0 (2026-06-08)
|
||||
|
||||
### Features
|
||||
|
||||
- add cookie export
|
||||
|
||||
### Refactoring
|
||||
|
||||
- deprecate camoufox
|
||||
- cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 3 updates (#421)
|
||||
- chore: update flake.nix for v0.25.3 [skip ci] (#417)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(rust)(deps): bump the rust-dependencies group (#422)
|
||||
|
||||
|
||||
## v0.25.3 (2026-06-03)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
## Features
|
||||
|
||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **Anti-detect Chromium engine** — powered by [Wayfern](https://wayfern.com), with advanced fingerprint spoofing
|
||||
- **Anti-detect Chromium engine** — powered by [Wayfern](https://wayfern.com), which comes with advanced fingerprint spoofing which naturally hides information in a way that is not detected by Cloudflare, reCaptcha v3, and other browser fingerprinting and anti-bot services.
|
||||
- **DNS AdBlocker** - block ads, trackers, and other unwanted content with per-profile DNS blocking
|
||||
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||
- **VPN support** — WireGuard configs per profile
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut-0.25.3-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut-0.25.3-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut-0.26.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.25.3";
|
||||
releaseVersion = "0.26.0";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_amd64.AppImage";
|
||||
hash = "sha256-GB+HMfMQuZj0YYibiyCD64u6o943anSI/1jyD36YJq4=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_amd64.AppImage";
|
||||
hash = "sha256-uwt8T+BeGf5NTFOj3D1gc8I9wkF02X2bJRpU3Yn5E2E=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.25.3/Donut_0.25.3_aarch64.AppImage";
|
||||
hash = "sha256-IKpz8AI3uM4+VxiF+8fwhj/mLn0KZW1KQMo3lGCTO8g=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.26.0/Donut_0.26.0_aarch64.AppImage";
|
||||
hash = "sha256-aLXoN5S+gNQJOXrLrTYeBUAckITcTNJUGTk/ZfGhpJA=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
Generated
+17
-1
@@ -1853,6 +1853,7 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-window-state",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -3089,7 +3090,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core 0.61.2",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6858,6 +6859,21 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-window-state"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.2"
|
||||
|
||||
@@ -41,6 +41,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
|
||||
@@ -162,6 +162,11 @@ async fn main() {
|
||||
Arg::new("blocklist-file")
|
||||
.long("blocklist-file")
|
||||
.help("Path to DNS blocklist file (one domain per line)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("local-protocol")
|
||||
.long("local-protocol")
|
||||
.help("Protocol served to the browser: http (default) or socks5"),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
@@ -251,6 +256,7 @@ async fn main() {
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
||||
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
|
||||
|
||||
match start_proxy_process_with_profile(
|
||||
upstream_url,
|
||||
@@ -258,6 +264,7 @@ async fn main() {
|
||||
profile_id,
|
||||
bypass_rules,
|
||||
blocklist_file,
|
||||
local_protocol,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -261,6 +261,11 @@ impl BrowserRunner {
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
// Camoufox (Firefox 150, and Firefox 135 on the not-yet-updated
|
||||
// Windows build) keeps the local HTTP proxy: Firefox's QUIC stack
|
||||
// bypasses a configured proxy, so QUIC is disabled and HTTP CONNECT
|
||||
// covers everything. SOCKS5 is reserved for Wayfern.
|
||||
"http",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -527,6 +532,11 @@ impl BrowserRunner {
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
blocklist_file,
|
||||
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
|
||||
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
|
||||
// leaking the real IP, rather than being forced direct as they
|
||||
// would be over an HTTP CONNECT proxy.
|
||||
"socks5",
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -535,8 +545,9 @@ impl BrowserRunner {
|
||||
error_msg
|
||||
})?;
|
||||
|
||||
// Format proxy URL for wayfern - always use HTTP for the local proxy
|
||||
let proxy_url = format!("http://{}:{}", local_proxy.host, local_proxy.port);
|
||||
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so
|
||||
// Chromium proxies UDP (QUIC/WebRTC), not just TCP.
|
||||
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
|
||||
|
||||
// Set proxy in wayfern config
|
||||
wayfern_config.proxy = Some(proxy_url);
|
||||
|
||||
+53
-2
@@ -43,6 +43,7 @@ pub mod proxy_runner;
|
||||
pub mod proxy_server;
|
||||
pub mod proxy_storage;
|
||||
mod settings_manager;
|
||||
pub mod socks5_local;
|
||||
pub mod sync;
|
||||
mod synchronizer;
|
||||
pub mod traffic_stats;
|
||||
@@ -150,6 +151,8 @@ use api_server::{get_api_server_status, start_api_server, stop_api_server};
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String>;
|
||||
#[cfg(target_os = "macos")]
|
||||
fn disable_native_fullscreen(&self) -> Result<(), String>;
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||
@@ -164,7 +167,7 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||
|
||||
if transparent {
|
||||
// Hide the title text
|
||||
ns_window.setTitleVisibility(NSWindowTitleVisibility(2)); // NSWindowTitleHidden
|
||||
ns_window.setTitleVisibility(NSWindowTitleVisibility(1)); // NSWindowTitleHidden
|
||||
|
||||
// Make titlebar transparent
|
||||
ns_window.setTitlebarAppearsTransparent(true);
|
||||
@@ -189,6 +192,33 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn disable_native_fullscreen(&self) -> Result<(), String> {
|
||||
use objc2::rc::Retained;
|
||||
use objc2_app_kit::{NSWindow, NSWindowCollectionBehavior};
|
||||
|
||||
unsafe {
|
||||
let ns_window: Retained<NSWindow> =
|
||||
Retained::retain(self.ns_window().unwrap().cast()).unwrap();
|
||||
|
||||
// Make the green title-bar button (and titlebar double-click) "zoom"
|
||||
// the window to fill the screen as an ordinary window instead of
|
||||
// entering immersive native fullscreen that hides the menu bar and
|
||||
// moves to its own Space. Mirrors Electron's `fullscreenable: false`:
|
||||
// clear FullScreenPrimary and set FullScreenNone. AppKit then maps the
|
||||
// green button to the standard zoom, expanding to the visible screen
|
||||
// frame while keeping the window chrome and the current Space.
|
||||
const FULL_SCREEN_PRIMARY: usize = 1 << 7;
|
||||
const FULL_SCREEN_NONE: usize = 1 << 9;
|
||||
let current = ns_window.collectionBehavior();
|
||||
let updated =
|
||||
NSWindowCollectionBehavior((current.0 & !FULL_SCREEN_PRIMARY) | FULL_SCREEN_NONE);
|
||||
ns_window.setCollectionBehavior(updated);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Called internally for deep-link / startup URL handling — not invoked from the
|
||||
@@ -1388,6 +1418,21 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
// Persist window size/position across restarts. VISIBLE is excluded
|
||||
// because the app hides to tray: restoring visibility would otherwise
|
||||
// relaunch with an invisible window after quitting from the tray while
|
||||
// hidden. FULLSCREEN is excluded because native fullscreen is disabled
|
||||
// (the green button zooms instead) — the maximized flag captures the
|
||||
// "filled screen" state, including green-button zoom on macOS.
|
||||
.plugin(
|
||||
tauri_plugin_window_state::Builder::default()
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE
|
||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.setup(|app| {
|
||||
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
||||
ephemeral_dirs::recover_ephemeral_dirs();
|
||||
@@ -1403,7 +1448,8 @@ pub fn run() {
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.title("Donut Browser")
|
||||
.inner_size(880.0, 500.0)
|
||||
.resizable(false)
|
||||
.min_inner_size(640.0, 400.0)
|
||||
.resizable(true)
|
||||
.fullscreen(false)
|
||||
.center()
|
||||
.focused(true)
|
||||
@@ -1447,6 +1493,11 @@ pub fn run() {
|
||||
if let Err(e) = window.set_transparent_titlebar(true) {
|
||||
log::warn!("Failed to set transparent titlebar: {e}");
|
||||
}
|
||||
// Green title-bar button maximizes (zoom) the window rather than
|
||||
// entering immersive native fullscreen.
|
||||
if let Err(e) = window.disable_native_fullscreen() {
|
||||
log::warn!("Failed to disable native fullscreen: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Set up deep link handler
|
||||
|
||||
@@ -1478,6 +1478,7 @@ impl ProxyManager {
|
||||
|
||||
// Start a proxy for given proxy settings and associate it with a browser process ID
|
||||
// If proxy_settings is None, starts a direct proxy for traffic monitoring
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn start_proxy(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1486,6 +1487,10 @@ impl ProxyManager {
|
||||
profile_id: Option<&str>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
// Protocol the local worker serves the browser: "http" (Camoufox) or
|
||||
// "socks5" (Wayfern). Reflected in the returned ProxySettings.proxy_type
|
||||
// so the caller formats the right local proxy URL scheme.
|
||||
local_protocol: &str,
|
||||
) -> Result<ProxySettings, String> {
|
||||
if let Some(name) = profile_id {
|
||||
// Check if we have an active proxy recorded for this profile
|
||||
@@ -1519,7 +1524,7 @@ impl ProxyManager {
|
||||
if proxies.contains_key(&browser_pid) {
|
||||
// Already mapped, reuse it
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
proxy_type: local_protocol.to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: existing.local_port,
|
||||
username: None,
|
||||
@@ -1559,7 +1564,7 @@ impl ProxyManager {
|
||||
if profile_id_matches {
|
||||
// Reuse existing local proxy (settings and profile_id match)
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
proxy_type: local_protocol.to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: existing.local_port,
|
||||
username: None,
|
||||
@@ -1618,6 +1623,9 @@ impl ProxyManager {
|
||||
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
||||
}
|
||||
|
||||
// Tell the worker which protocol to serve the browser (http or socks5)
|
||||
proxy_cmd = proxy_cmd.arg("--local-protocol").arg(local_protocol);
|
||||
|
||||
// Execute the command and wait for it to complete
|
||||
// The donut-proxy binary should start the worker and then exit
|
||||
let output = proxy_cmd
|
||||
@@ -1709,7 +1717,7 @@ impl ProxyManager {
|
||||
|
||||
// Return proxy settings for the browser
|
||||
Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
proxy_type: local_protocol.to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy_info.local_port,
|
||||
username: None,
|
||||
@@ -2885,6 +2893,7 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
};
|
||||
let dead_config = ProxyConfig {
|
||||
id: dead_id.clone(),
|
||||
@@ -2896,6 +2905,7 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
};
|
||||
|
||||
save_proxy_config(&live_config).unwrap();
|
||||
@@ -2935,6 +2945,7 @@ mod tests {
|
||||
profile_id: Some("prof_abc".to_string()),
|
||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
};
|
||||
|
||||
// Save
|
||||
@@ -3253,6 +3264,7 @@ mod tests {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
};
|
||||
save_proxy_config(&config).unwrap();
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ pub async fn start_proxy_process(
|
||||
upstream_url: Option<String>,
|
||||
port: Option<u16>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None).await
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await
|
||||
}
|
||||
|
||||
pub async fn start_proxy_process_with_profile(
|
||||
@@ -169,6 +169,7 @@ pub async fn start_proxy_process_with_profile(
|
||||
profile_id: Option<String>,
|
||||
bypass_rules: Vec<String>,
|
||||
blocklist_file: Option<String>,
|
||||
local_protocol: Option<String>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
let id = generate_proxy_id();
|
||||
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
||||
@@ -183,7 +184,8 @@ pub async fn start_proxy_process_with_profile(
|
||||
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
|
||||
.with_profile_id(profile_id.clone())
|
||||
.with_bypass_rules(bypass_rules)
|
||||
.with_blocklist_file(blocklist_file);
|
||||
.with_blocklist_file(blocklist_file)
|
||||
.with_local_protocol(local_protocol);
|
||||
save_proxy_config(&config)?;
|
||||
|
||||
// Log profile_id for debugging
|
||||
|
||||
@@ -21,9 +21,9 @@ use tokio::net::TcpStream;
|
||||
/// Combined read+write trait for tunnel target streams, allowing
|
||||
/// `handle_connect_from_buffer` to handle plain TCP, SOCKS, and
|
||||
/// Shadowsocks through the same bidirectional-copy path.
|
||||
trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
pub(crate) trait AsyncStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> AsyncStream for T {}
|
||||
type BoxedAsyncStream = Box<dyn AsyncStream>;
|
||||
pub(crate) type BoxedAsyncStream = Box<dyn AsyncStream>;
|
||||
use url::Url;
|
||||
|
||||
enum CompiledRule {
|
||||
@@ -1247,10 +1247,19 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
|
||||
log::info!("Successfully bound to port {}", actual_port);
|
||||
|
||||
// Update config with actual port and local_url
|
||||
// Protocol served to the browser: "socks5" (Wayfern) or "http" (default).
|
||||
let local_protocol = config.local_protocol_or_default();
|
||||
let serve_socks5 = local_protocol == "socks5";
|
||||
|
||||
// Update config with actual port and local_url (scheme matches the protocol
|
||||
// we serve, so the parent's readiness check and any consumer see the truth)
|
||||
let mut updated_config = config.clone();
|
||||
updated_config.local_port = Some(actual_port);
|
||||
updated_config.local_url = Some(format!("http://127.0.0.1:{}", actual_port));
|
||||
updated_config.local_url = Some(format!(
|
||||
"{}://127.0.0.1:{}",
|
||||
if serve_socks5 { "socks5" } else { "http" },
|
||||
actual_port
|
||||
));
|
||||
|
||||
if !crate::proxy_storage::update_proxy_config(&updated_config) {
|
||||
log::error!("Failed to update proxy config");
|
||||
@@ -1371,9 +1380,15 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
let upstream = upstream_url.clone();
|
||||
let matcher = bypass_matcher.clone();
|
||||
let blocker = blocklist_matcher.clone();
|
||||
tokio::task::spawn(async move {
|
||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
if serve_socks5 {
|
||||
tokio::task::spawn(async move {
|
||||
crate::socks5_local::handle_socks5_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
} else {
|
||||
tokio::task::spawn(async move {
|
||||
handle_proxy_connection(stream, upstream, matcher, blocker).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error accepting connection: {:?}", e);
|
||||
@@ -1444,20 +1459,51 @@ async fn handle_connect_from_buffer(
|
||||
);
|
||||
|
||||
// Connect to target (directly or via upstream proxy).
|
||||
// Returns a BoxedAsyncStream so all upstream types (plain TCP, SOCKS,
|
||||
// Shadowsocks) share the same bidirectional-copy tunnel code below.
|
||||
let target_stream = connect_to_target_via_upstream(
|
||||
target_host,
|
||||
target_port,
|
||||
upstream_url.as_deref(),
|
||||
&bypass_matcher,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Send 200 Connection Established response to client
|
||||
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
|
||||
client_stream
|
||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
.await?;
|
||||
client_stream.flush().await?;
|
||||
|
||||
log::trace!("Sent 200 Connection Established response, starting tunnel");
|
||||
|
||||
tunnel_streams(client_stream, target_stream, domain).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Establish a stream to `target_host:target_port`, either directly or through
|
||||
/// the configured upstream proxy. Shared by the HTTP CONNECT path and the
|
||||
/// local SOCKS5 server so every upstream type (direct, HTTP/HTTPS CONNECT,
|
||||
/// SOCKS4/5, Shadowsocks) is dialed in exactly one place. Returns a
|
||||
/// `BoxedAsyncStream` so the caller can tunnel over any upstream uniformly.
|
||||
pub(crate) async fn connect_to_target_via_upstream(
|
||||
target_host: &str,
|
||||
target_port: u16,
|
||||
upstream_url: Option<&str>,
|
||||
bypass_matcher: &BypassMatcher,
|
||||
) -> Result<BoxedAsyncStream, Box<dyn std::error::Error>> {
|
||||
let should_bypass = bypass_matcher.should_bypass(target_host);
|
||||
// Helper: configure outbound TCP to match browser TCP fingerprint
|
||||
let configure_tcp = |stream: &TcpStream| {
|
||||
let _ = stream.set_nodelay(true);
|
||||
};
|
||||
let target_stream: BoxedAsyncStream = match upstream_url.as_ref() {
|
||||
let target_stream: BoxedAsyncStream = match upstream_url {
|
||||
None => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
}
|
||||
Some(url) if url == "DIRECT" => {
|
||||
Some("DIRECT") => {
|
||||
let s = TcpStream::connect((target_host, target_port)).await?;
|
||||
configure_tcp(&s);
|
||||
Box::new(s)
|
||||
@@ -1616,20 +1662,18 @@ async fn handle_connect_from_buffer(
|
||||
}
|
||||
};
|
||||
|
||||
// TCP_NODELAY is set per-stream where applicable (TcpStream paths).
|
||||
// For encrypted streams (Shadowsocks), the underlying TCP connection
|
||||
// is managed by the library and nodelay is handled internally.
|
||||
Ok(target_stream)
|
||||
}
|
||||
|
||||
// Send 200 Connection Established response to client
|
||||
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
|
||||
client_stream
|
||||
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
||||
.await?;
|
||||
client_stream.flush().await?;
|
||||
|
||||
log::trace!("Sent 200 Connection Established response, starting tunnel");
|
||||
|
||||
// Now tunnel data bidirectionally with counting
|
||||
/// Bidirectionally relay `client_stream` <-> `target_stream` until either side
|
||||
/// closes, counting bytes for traffic stats and attributing them to `domain`.
|
||||
/// The caller is responsible for having already sent any protocol-specific
|
||||
/// success reply (HTTP `200` or SOCKS5 reply) before calling this.
|
||||
pub(crate) async fn tunnel_streams(
|
||||
client_stream: TcpStream,
|
||||
target_stream: BoxedAsyncStream,
|
||||
domain: String,
|
||||
) {
|
||||
// Wrap streams to count bytes transferred
|
||||
let counting_client = CountingStream::new(client_stream);
|
||||
let counting_target = CountingStream::new(target_stream);
|
||||
@@ -1692,8 +1736,6 @@ async fn handle_connect_from_buffer(
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
tracker.update_domain_bytes(&domain, final_sent, final_recv);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -16,6 +16,12 @@ pub struct ProxyConfig {
|
||||
pub bypass_rules: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub blocklist_file: Option<String>,
|
||||
/// Protocol the local worker serves to the browser: "http" (default, used
|
||||
/// by Camoufox/Firefox) or "socks5" (used by Wayfern/Chromium so QUIC and
|
||||
/// WebRTC UDP can be proxied without leaking the real IP). Independent of
|
||||
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
|
||||
#[serde(default)]
|
||||
pub local_protocol: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
@@ -30,6 +36,7 @@ impl ProxyConfig {
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
blocklist_file: None,
|
||||
local_protocol: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +54,20 @@ impl ProxyConfig {
|
||||
self.blocklist_file = blocklist_file;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
|
||||
self.local_protocol = local_protocol;
|
||||
self
|
||||
}
|
||||
|
||||
/// "socks5" or "http" (default). Lowercased for case-insensitive matching.
|
||||
pub fn local_protocol_or_default(&self) -> String {
|
||||
self
|
||||
.local_protocol
|
||||
.as_deref()
|
||||
.unwrap_or("http")
|
||||
.to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_storage_dir() -> PathBuf {
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
//! Local SOCKS5 server served to the browser (Wayfern/Chromium).
|
||||
//!
|
||||
//! The HTTP front-end (`proxy_server::handle_proxy_connection`) can only tunnel
|
||||
//! TCP, so QUIC and WebRTC — which are UDP — would be forced direct and leak the
|
||||
//! real IP. Serving SOCKS5 instead lets Chromium proxy UDP via SOCKS5 UDP
|
||||
//! ASSOCIATE (RFC 1928). TCP CONNECT reuses the exact same upstream-dial and
|
||||
//! tunnel code as the HTTP path, so every upstream type (direct, HTTP/HTTPS
|
||||
//! CONNECT, SOCKS4/5, Shadowsocks) behaves identically.
|
||||
//!
|
||||
//! UDP ASSOCIATE is leak-safe by construction: UDP is only relayed where it
|
||||
//! cannot expose the host IP — directly when there is no upstream proxy, or
|
||||
//! tunneled through a UDP-capable SOCKS5 upstream. For upstreams that cannot
|
||||
//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses
|
||||
//! the association) the request is refused, so Chromium falls back to proxied
|
||||
//! TCP rather than sending UDP from the real IP.
|
||||
|
||||
use crate::proxy_server::{
|
||||
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
|
||||
};
|
||||
use crate::traffic_stats::get_traffic_tracker;
|
||||
use async_socks5::{AddrKind, Auth, SocksDatagram};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpStream, UdpSocket};
|
||||
use url::Url;
|
||||
|
||||
// SOCKS5 reply codes (RFC 1928 §6).
|
||||
const REP_SUCCEEDED: u8 = 0x00;
|
||||
const REP_GENERAL_FAILURE: u8 = 0x01;
|
||||
const REP_NOT_ALLOWED: u8 = 0x02;
|
||||
const REP_COMMAND_NOT_SUPPORTED: u8 = 0x07;
|
||||
|
||||
// SOCKS5 commands (RFC 1928 §4).
|
||||
const CMD_CONNECT: u8 = 0x01;
|
||||
const CMD_UDP_ASSOCIATE: u8 = 0x03;
|
||||
|
||||
// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack.
|
||||
const UDP_BUF: usize = 65_536;
|
||||
|
||||
/// How a UDP ASSOCIATE request must be served for a given upstream so the real
|
||||
/// IP never leaks.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum UdpMode {
|
||||
/// No upstream proxy: relay UDP directly (the host IP is the profile's IP,
|
||||
/// so there is nothing to hide).
|
||||
Direct,
|
||||
/// SOCKS5 upstream: attempt SOCKS5 UDP ASSOCIATE against it. Tunnels UDP if
|
||||
/// the upstream grants it; refuses (no leak) if it does not.
|
||||
Socks5Upstream,
|
||||
/// Upstream that cannot carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks): refuse so
|
||||
/// Chromium falls back to proxied TCP instead of leaking UDP.
|
||||
Refuse,
|
||||
}
|
||||
|
||||
/// Decide the leak-safe UDP policy for an upstream URL.
|
||||
fn udp_mode(upstream_url: Option<&str>) -> UdpMode {
|
||||
match upstream_url {
|
||||
None => UdpMode::Direct,
|
||||
Some("DIRECT") => UdpMode::Direct,
|
||||
Some(url) => match Url::parse(url).ok().map(|u| u.scheme().to_lowercase()) {
|
||||
Some(scheme) if scheme == "socks5" => UdpMode::Socks5Upstream,
|
||||
// http / https / socks4 / ss / shadowsocks / anything else: TCP-only.
|
||||
_ => UdpMode::Refuse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `0.0.0.0:0` — used for BND fields in replies where the bound address is
|
||||
/// irrelevant to the client (e.g. CONNECT).
|
||||
fn unspecified() -> SocketAddr {
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0)
|
||||
}
|
||||
|
||||
/// Handle one SOCKS5 client connection from the browser. Mirrors the spawn
|
||||
/// contract of `proxy_server::handle_proxy_connection`.
|
||||
pub async fn handle_socks5_connection(
|
||||
mut stream: TcpStream,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
if let Err(e) = negotiate_method(&mut stream).await {
|
||||
log::debug!("SOCKS5 method negotiation failed: {e}");
|
||||
return;
|
||||
}
|
||||
|
||||
let request = match read_request(&mut stream).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log::debug!("SOCKS5 request parse failed: {e}");
|
||||
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match request.cmd {
|
||||
CMD_CONNECT => {
|
||||
handle_connect(
|
||||
stream,
|
||||
request.host,
|
||||
request.port,
|
||||
upstream_url,
|
||||
bypass_matcher,
|
||||
blocklist_matcher,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
CMD_UDP_ASSOCIATE => {
|
||||
handle_udp_associate(stream, upstream_url).await;
|
||||
}
|
||||
other => {
|
||||
log::debug!("SOCKS5 unsupported command {other:#04x}");
|
||||
let _ = send_reply(&mut stream, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the SOCKS5 greeting and select the no-auth method. The local proxy is
|
||||
/// loopback-only, so no authentication is required (Chromium offers no-auth).
|
||||
async fn negotiate_method(stream: &mut TcpStream) -> std::io::Result<()> {
|
||||
let mut head = [0u8; 2];
|
||||
stream.read_exact(&mut head).await?;
|
||||
if head[0] != 0x05 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"not a SOCKS5 greeting",
|
||||
));
|
||||
}
|
||||
let nmethods = head[1] as usize;
|
||||
let mut methods = vec![0u8; nmethods];
|
||||
stream.read_exact(&mut methods).await?;
|
||||
|
||||
if methods.contains(&0x00) {
|
||||
stream.write_all(&[0x05, 0x00]).await?;
|
||||
Ok(())
|
||||
} else {
|
||||
// No acceptable methods.
|
||||
let _ = stream.write_all(&[0x05, 0xFF]).await;
|
||||
Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"no no-auth method offered",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
struct Socks5Request {
|
||||
cmd: u8,
|
||||
host: String,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
/// Read a SOCKS5 request line: VER, CMD, RSV, ATYP, DST.ADDR, DST.PORT.
|
||||
async fn read_request(stream: &mut TcpStream) -> std::io::Result<Socks5Request> {
|
||||
let mut head = [0u8; 4];
|
||||
stream.read_exact(&mut head).await?;
|
||||
if head[0] != 0x05 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"bad SOCKS5 request version",
|
||||
));
|
||||
}
|
||||
let cmd = head[1];
|
||||
let atyp = head[3];
|
||||
let host = read_addr(stream, atyp).await?;
|
||||
let mut port = [0u8; 2];
|
||||
stream.read_exact(&mut port).await?;
|
||||
Ok(Socks5Request {
|
||||
cmd,
|
||||
host,
|
||||
port: u16::from_be_bytes(port),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a SOCKS5 address of the given type into a host string (an IP literal or
|
||||
/// a domain name; `connect_to_target_via_upstream` handles both).
|
||||
async fn read_addr(stream: &mut TcpStream, atyp: u8) -> std::io::Result<String> {
|
||||
match atyp {
|
||||
0x01 => {
|
||||
let mut b = [0u8; 4];
|
||||
stream.read_exact(&mut b).await?;
|
||||
Ok(Ipv4Addr::new(b[0], b[1], b[2], b[3]).to_string())
|
||||
}
|
||||
0x04 => {
|
||||
let mut b = [0u8; 16];
|
||||
stream.read_exact(&mut b).await?;
|
||||
Ok(Ipv6Addr::from(b).to_string())
|
||||
}
|
||||
0x03 => {
|
||||
let mut len = [0u8; 1];
|
||||
stream.read_exact(&mut len).await?;
|
||||
let mut domain = vec![0u8; len[0] as usize];
|
||||
stream.read_exact(&mut domain).await?;
|
||||
Ok(String::from_utf8_lossy(&domain).to_string())
|
||||
}
|
||||
other => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("unsupported SOCKS5 address type {other:#04x}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a SOCKS5 reply with the given code and bound address.
|
||||
async fn send_reply(stream: &mut TcpStream, rep: u8, bnd: SocketAddr) -> std::io::Result<()> {
|
||||
let mut resp = vec![0x05, rep, 0x00];
|
||||
push_addr(&mut resp, bnd);
|
||||
stream.write_all(&resp).await
|
||||
}
|
||||
|
||||
/// Append an ATYP + address + port to a SOCKS5 message buffer.
|
||||
fn push_addr(buf: &mut Vec<u8>, addr: SocketAddr) {
|
||||
match addr.ip() {
|
||||
IpAddr::V4(v4) => {
|
||||
buf.push(0x01);
|
||||
buf.extend_from_slice(&v4.octets());
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
buf.push(0x04);
|
||||
buf.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
buf.extend_from_slice(&addr.port().to_be_bytes());
|
||||
}
|
||||
|
||||
/// SOCKS5 CONNECT: dial the target via the upstream and bidirectionally tunnel,
|
||||
/// reusing the same code path as the HTTP CONNECT proxy.
|
||||
async fn handle_connect(
|
||||
mut stream: TcpStream,
|
||||
host: String,
|
||||
port: u16,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
blocklist_matcher: BlocklistMatcher,
|
||||
) {
|
||||
if blocklist_matcher.is_blocked(&host) {
|
||||
log::debug!("[blocklist] Blocked SOCKS5 CONNECT to {host}");
|
||||
let _ = send_reply(&mut stream, REP_NOT_ALLOWED, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(tracker) = get_traffic_tracker() {
|
||||
tracker.record_request(&host, 0, 0);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"SOCKS5 CONNECT {}:{} (upstream={})",
|
||||
host,
|
||||
port,
|
||||
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||
);
|
||||
|
||||
// Resolve to the target stream, logging and dropping the (non-Send) dial
|
||||
// error inside the match arm so it is never held across the await below.
|
||||
let target =
|
||||
match connect_to_target_via_upstream(&host, port, upstream_url.as_deref(), &bypass_matcher)
|
||||
.await
|
||||
{
|
||||
Ok(t) => Some(t),
|
||||
Err(e) => {
|
||||
log::warn!("SOCKS5 CONNECT to {host}:{port} failed: {e}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let Some(target) = target else {
|
||||
let _ = send_reply(&mut stream, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
};
|
||||
|
||||
if send_reply(&mut stream, REP_SUCCEEDED, unspecified())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
tunnel_streams(stream, target, host).await;
|
||||
}
|
||||
|
||||
/// SOCKS5 UDP ASSOCIATE, leak-safe per upstream (see [`UdpMode`]).
|
||||
///
|
||||
/// `control` is the TCP control connection; the UDP association lives exactly
|
||||
/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when
|
||||
/// the browser closes it.
|
||||
async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<String>) {
|
||||
let mode = udp_mode(upstream_url.as_deref());
|
||||
|
||||
if mode == UdpMode::Refuse {
|
||||
log::info!(
|
||||
"SOCKS5 UDP ASSOCIATE refused: upstream ({}) cannot carry UDP without leaking; Chromium will use proxied TCP",
|
||||
upstream_url.as_deref().unwrap_or("DIRECT")
|
||||
);
|
||||
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// The UDP relay socket the browser sends its datagrams to. Loopback-only.
|
||||
let relay = match UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to bind UDP relay socket: {e}");
|
||||
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let relay_addr = match relay.local_addr() {
|
||||
Ok(a) => a,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read UDP relay addr: {e}");
|
||||
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match mode {
|
||||
UdpMode::Direct => {
|
||||
// Bind the egress socket before replying so a failure surfaces as a
|
||||
// refusal (no half-open association).
|
||||
let out = match UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to bind UDP egress socket: {e}");
|
||||
let _ = send_reply(&mut control, REP_GENERAL_FAILURE, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
|
||||
run_udp_relay_direct(control, relay, out).await;
|
||||
}
|
||||
UdpMode::Socks5Upstream => {
|
||||
// Establish the upstream association FIRST; if the upstream refuses UDP,
|
||||
// refuse to the browser too (no leak).
|
||||
let upstream = upstream_url.as_deref().unwrap_or("");
|
||||
let datagram = match associate_upstream(upstream).await {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
log::info!(
|
||||
"SOCKS5 upstream did not grant UDP ASSOCIATE ({e}); refusing so Chromium uses proxied TCP"
|
||||
);
|
||||
let _ = send_reply(&mut control, REP_COMMAND_NOT_SUPPORTED, unspecified()).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if send_reply(&mut control, REP_SUCCEEDED, relay_addr)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
|
||||
run_udp_relay_socks5(control, relay, datagram).await;
|
||||
}
|
||||
UdpMode::Refuse => unreachable!("handled above"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a SOCKS5 UDP association against the upstream proxy.
|
||||
async fn associate_upstream(
|
||||
upstream_url: &str,
|
||||
) -> Result<SocksDatagram<TcpStream>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let upstream = Url::parse(upstream_url)?;
|
||||
let host = upstream.host_str().unwrap_or("127.0.0.1");
|
||||
let port = upstream.port().unwrap_or(1080);
|
||||
let auth = if !upstream.username().is_empty() {
|
||||
Some(Auth {
|
||||
username: upstream.username().to_string(),
|
||||
password: upstream.password().unwrap_or("").to_string(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let proxy_stream = TcpStream::connect((host, port)).await?;
|
||||
let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
|
||||
// association_addr None => 0.0.0.0:0 (we accept replies from any peer).
|
||||
let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>).await?;
|
||||
Ok(datagram)
|
||||
}
|
||||
|
||||
/// Parsed SOCKS5 UDP datagram header (RFC 1928 §7): the destination and the
|
||||
/// offset at which the payload begins. Fragmented datagrams (FRAG != 0) are
|
||||
/// rejected by the caller.
|
||||
struct UdpHeader {
|
||||
frag: u8,
|
||||
dst: AddrKind,
|
||||
data_offset: usize,
|
||||
}
|
||||
|
||||
fn parse_udp_header(buf: &[u8]) -> Option<UdpHeader> {
|
||||
if buf.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let frag = buf[2];
|
||||
let atyp = buf[3];
|
||||
match atyp {
|
||||
0x01 => {
|
||||
if buf.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let ip = Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
Some(UdpHeader {
|
||||
frag,
|
||||
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V4(ip), port)),
|
||||
data_offset: 10,
|
||||
})
|
||||
}
|
||||
0x04 => {
|
||||
if buf.len() < 22 {
|
||||
return None;
|
||||
}
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&buf[4..20]);
|
||||
let ip = Ipv6Addr::from(octets);
|
||||
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||
Some(UdpHeader {
|
||||
frag,
|
||||
dst: AddrKind::Ip(SocketAddr::new(IpAddr::V6(ip), port)),
|
||||
data_offset: 22,
|
||||
})
|
||||
}
|
||||
0x03 => {
|
||||
let dlen = *buf.get(4)? as usize;
|
||||
let needed = 5 + dlen + 2;
|
||||
if buf.len() < needed {
|
||||
return None;
|
||||
}
|
||||
let domain = String::from_utf8_lossy(&buf[5..5 + dlen]).to_string();
|
||||
let port = u16::from_be_bytes([buf[5 + dlen], buf[6 + dlen]]);
|
||||
Some(UdpHeader {
|
||||
frag,
|
||||
dst: AddrKind::Domain(domain, port),
|
||||
data_offset: needed,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a SOCKS5 UDP response datagram (header + payload) to send back to the
|
||||
/// browser, naming `peer` as the source.
|
||||
fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
|
||||
push_addr(&mut out, peer);
|
||||
out.extend_from_slice(data);
|
||||
out
|
||||
}
|
||||
|
||||
/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when
|
||||
/// there is no upstream proxy, so the host IP is the profile's own IP.
|
||||
async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) {
|
||||
let mut client_addr: Option<SocketAddr> = None;
|
||||
let mut from_client = vec![0u8; UDP_BUF];
|
||||
let mut from_target = vec![0u8; UDP_BUF];
|
||||
let mut ctrl_buf = [0u8; 256];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
// Control connection closed => association ends.
|
||||
r = control.read(&mut ctrl_buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {} // ignore any data on the control channel
|
||||
}
|
||||
}
|
||||
// Browser -> target.
|
||||
r = relay.recv_from(&mut from_client) => {
|
||||
let Ok((n, src)) = r else { break };
|
||||
client_addr = Some(src);
|
||||
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
|
||||
if header.frag != 0 {
|
||||
continue; // fragmentation unsupported
|
||||
}
|
||||
let payload = &from_client[header.data_offset..n];
|
||||
let dst = match resolve_addr(&header.dst).await {
|
||||
Some(d) => d,
|
||||
None => continue,
|
||||
};
|
||||
let _ = out.send_to(payload, dst).await;
|
||||
}
|
||||
// Target -> browser.
|
||||
r = out.recv_from(&mut from_target) => {
|
||||
let Ok((n, peer)) = r else { continue };
|
||||
if let Some(client) = client_addr {
|
||||
let resp = build_udp_response(peer, &from_target[..n]);
|
||||
let _ = relay.send_to(&resp, client).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
|
||||
async fn run_udp_relay_socks5(
|
||||
mut control: TcpStream,
|
||||
relay: UdpSocket,
|
||||
datagram: SocksDatagram<TcpStream>,
|
||||
) {
|
||||
let mut client_addr: Option<SocketAddr> = None;
|
||||
let mut from_client = vec![0u8; UDP_BUF];
|
||||
let mut from_upstream = vec![0u8; UDP_BUF];
|
||||
let mut ctrl_buf = [0u8; 256];
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = control.read(&mut ctrl_buf) => {
|
||||
match r {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
// Browser -> upstream.
|
||||
r = relay.recv_from(&mut from_client) => {
|
||||
let Ok((n, src)) = r else { break };
|
||||
client_addr = Some(src);
|
||||
let Some(header) = parse_udp_header(&from_client[..n]) else { continue };
|
||||
if header.frag != 0 {
|
||||
continue;
|
||||
}
|
||||
let payload = from_client[header.data_offset..n].to_vec();
|
||||
let _ = datagram.send_to(&payload, header.dst).await;
|
||||
}
|
||||
// Upstream -> browser.
|
||||
r = datagram.recv_from(&mut from_upstream) => {
|
||||
let Ok((n, peer)) = r else { continue };
|
||||
if let Some(client) = client_addr {
|
||||
let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]);
|
||||
let _ = relay.send_to(&resp, client).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a UDP destination to a concrete socket address for direct relay.
|
||||
async fn resolve_addr(addr: &AddrKind) -> Option<SocketAddr> {
|
||||
match addr {
|
||||
AddrKind::Ip(s) => Some(*s),
|
||||
AddrKind::Domain(domain, port) => tokio::net::lookup_host(format!("{domain}:{port}"))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut it| it.next()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort conversion of an upstream-reported source address into a
|
||||
/// `SocketAddr` for the response header. A domain (rare for UDP) collapses to
|
||||
/// `0.0.0.0:port`, which clients treat as "from the proxy".
|
||||
fn addrkind_to_socketaddr(addr: &AddrKind) -> SocketAddr {
|
||||
match addr {
|
||||
AddrKind::Ip(s) => *s,
|
||||
AddrKind::Domain(_, port) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), *port),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn udp_mode_direct_for_none_and_direct() {
|
||||
assert_eq!(udp_mode(None), UdpMode::Direct);
|
||||
assert_eq!(udp_mode(Some("DIRECT")), UdpMode::Direct);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_mode_socks5_upstream() {
|
||||
assert_eq!(
|
||||
udp_mode(Some("socks5://user:pass@1.2.3.4:1080")),
|
||||
UdpMode::Socks5Upstream
|
||||
);
|
||||
assert_eq!(
|
||||
udp_mode(Some("socks5://1.2.3.4:1080")),
|
||||
UdpMode::Socks5Upstream
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_mode_refuses_tcp_only_upstreams() {
|
||||
// HTTP/HTTPS CONNECT, SOCKS4, and Shadowsocks cannot carry UDP, so UDP
|
||||
// ASSOCIATE must be refused (Chromium then uses proxied TCP — no leak).
|
||||
assert_eq!(udp_mode(Some("http://1.2.3.4:8080")), UdpMode::Refuse);
|
||||
assert_eq!(udp_mode(Some("https://1.2.3.4:8080")), UdpMode::Refuse);
|
||||
assert_eq!(udp_mode(Some("socks4://1.2.3.4:1080")), UdpMode::Refuse);
|
||||
assert_eq!(
|
||||
udp_mode(Some("ss://aes-256-gcm:pw@1.2.3.4:8388")),
|
||||
UdpMode::Refuse
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_udp_header_ipv4() {
|
||||
// RSV RSV FRAG ATYP=1 1.2.3.4 :443 payload="hi"
|
||||
let buf = [0, 0, 0, 0x01, 1, 2, 3, 4, 0x01, 0xBB, b'h', b'i'];
|
||||
let h = parse_udp_header(&buf).expect("ipv4 header");
|
||||
assert_eq!(h.frag, 0);
|
||||
assert_eq!(h.data_offset, 10);
|
||||
assert_eq!(
|
||||
h.dst,
|
||||
AddrKind::Ip(SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)), 443))
|
||||
);
|
||||
assert_eq!(&buf[h.data_offset..], b"hi");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_udp_header_domain() {
|
||||
// ATYP=3, len=3, "abc", port 8080, payload "x"
|
||||
let mut buf = vec![0, 0, 0, 0x03, 3, b'a', b'b', b'c', 0x1F, 0x90];
|
||||
buf.push(b'x');
|
||||
let h = parse_udp_header(&buf).expect("domain header");
|
||||
assert_eq!(h.dst, AddrKind::Domain("abc".to_string(), 8080));
|
||||
assert_eq!(&buf[h.data_offset..], b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_udp_header_rejects_truncated() {
|
||||
assert!(parse_udp_header(&[0, 0, 0]).is_none());
|
||||
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_udp_response_prefixes_header() {
|
||||
let resp = build_udp_response(
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53),
|
||||
b"data",
|
||||
);
|
||||
// RSV RSV FRAG ATYP=1 9.9.9.9 :53 "data"
|
||||
assert_eq!(
|
||||
resp,
|
||||
vec![0, 0, 0, 0x01, 9, 9, 9, 9, 0x00, 0x35, b'd', b'a', b't', b'a']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ use boringtun::x25519::{PublicKey, StaticSecret};
|
||||
use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet};
|
||||
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
|
||||
use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer};
|
||||
use smoltcp::socket::udp;
|
||||
use smoltcp::time::Instant as SmolInstant;
|
||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address};
|
||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, IpEndpoint, Ipv4Address};
|
||||
use std::collections::VecDeque;
|
||||
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -13,6 +14,58 @@ use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const SMOLTCP_TCP_RX_BUF: usize = 65536;
|
||||
const SMOLTCP_TCP_TX_BUF: usize = 65536;
|
||||
const SMOLTCP_UDP_BUF: usize = 65536;
|
||||
|
||||
/// Parse an RFC 1928 §7 UDP request header. Returns the destination endpoint
|
||||
/// and the payload offset, or None if malformed, fragmented, or domain-typed.
|
||||
/// Only literal IPs are routed through the tunnel: resolving a domain on the
|
||||
/// host would leak DNS, and QUIC/WebRTC datagrams always carry literal IPs.
|
||||
fn parse_udp_datagram(buf: &[u8]) -> Option<(IpEndpoint, usize)> {
|
||||
if buf.len() < 4 || buf[2] != 0 {
|
||||
// too short, or FRAG != 0 (fragmentation unsupported)
|
||||
return None;
|
||||
}
|
||||
match buf[3] {
|
||||
0x01 => {
|
||||
if buf.len() < 10 {
|
||||
return None;
|
||||
}
|
||||
let ip = Ipv4Address::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
Some((IpEndpoint::new(IpAddress::Ipv4(ip), port), 10))
|
||||
}
|
||||
0x04 => {
|
||||
if buf.len() < 22 {
|
||||
return None;
|
||||
}
|
||||
let mut o = [0u8; 16];
|
||||
o.copy_from_slice(&buf[4..20]);
|
||||
let ip = smoltcp::wire::Ipv6Address::from(o);
|
||||
let port = u16::from_be_bytes([buf[20], buf[21]]);
|
||||
Some((IpEndpoint::new(IpAddress::Ipv6(ip), port), 22))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap a tunnel-received datagram in an RFC 1928 §7 UDP reply header naming
|
||||
/// `src` as the origin, for delivery back to the browser's relay socket.
|
||||
fn build_udp_datagram(src: IpEndpoint, payload: &[u8]) -> Vec<u8> {
|
||||
let mut out = vec![0x00, 0x00, 0x00]; // RSV(2) + FRAG(0)
|
||||
match src.addr {
|
||||
IpAddress::Ipv4(v4) => {
|
||||
out.push(0x01);
|
||||
out.extend_from_slice(&v4.octets());
|
||||
}
|
||||
IpAddress::Ipv6(v6) => {
|
||||
out.push(0x04);
|
||||
out.extend_from_slice(&v6.octets());
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&src.port.to_be_bytes());
|
||||
out.extend_from_slice(payload);
|
||||
out
|
||||
}
|
||||
|
||||
struct WgDevice {
|
||||
tunn: Arc<Mutex<Box<Tunn>>>,
|
||||
@@ -432,6 +485,15 @@ impl WireGuardSocks5Server {
|
||||
|
||||
let mut sockets = SocketSet::new(vec![]);
|
||||
|
||||
// A live SOCKS5 UDP ASSOCIATE: the loopback relay socket the browser sends
|
||||
// datagrams to, and the browser's learned source address. The tunnel-side
|
||||
// smoltcp UDP socket lives in `sockets`, keyed by the connection's
|
||||
// (repurposed) `smol_handle`.
|
||||
struct UdpAssoc {
|
||||
relay: UdpSocket,
|
||||
client_addr: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
struct Connection {
|
||||
smol_handle: SocketHandle,
|
||||
tcp_stream: TcpStream,
|
||||
@@ -440,6 +502,7 @@ impl WireGuardSocks5Server {
|
||||
greeting_done: bool,
|
||||
read_buf: Vec<u8>,
|
||||
dest_addr: Option<SocketAddr>,
|
||||
udp: Option<UdpAssoc>,
|
||||
}
|
||||
|
||||
let mut connections: Vec<Connection> = Vec::new();
|
||||
@@ -463,6 +526,7 @@ impl WireGuardSocks5Server {
|
||||
greeting_done: false,
|
||||
read_buf: Vec::new(),
|
||||
dest_addr: None,
|
||||
udp: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -540,8 +604,17 @@ impl WireGuardSocks5Server {
|
||||
}
|
||||
|
||||
if conn.greeting_done && conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
|
||||
// SOCKS5 connect request
|
||||
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
|
||||
// SOCKS5 request: CONNECT (0x01) or UDP ASSOCIATE (0x03)
|
||||
if conn.read_buf[0] != 0x05 {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
let cmd = conn.read_buf[1];
|
||||
if cmd != 0x01 && cmd != 0x03 {
|
||||
// command not supported
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
@@ -613,6 +686,75 @@ impl WireGuardSocks5Server {
|
||||
};
|
||||
|
||||
conn.read_buf.drain(..addr_len);
|
||||
|
||||
if cmd == 0x03 {
|
||||
// === SOCKS5 UDP ASSOCIATE ===
|
||||
// The request's DST is the client's intended source (typically
|
||||
// 0.0.0.0:0) and is ignored — the browser's relay source is
|
||||
// learned from its first datagram. Bind a loopback relay socket
|
||||
// the browser sends to, plus a smoltcp UDP socket that egresses
|
||||
// through the WireGuard tunnel on the interface IP.
|
||||
let relay = match UdpSocket::bind("127.0.0.1:0") {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let _ = relay.set_nonblocking(true);
|
||||
let relay_port = relay.local_addr().map(|a| a.port()).unwrap_or(0);
|
||||
|
||||
// Reply with the relay endpoint (127.0.0.1:relay_port).
|
||||
if conn
|
||||
.tcp_stream
|
||||
.try_write(&[
|
||||
0x05,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
127,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
(relay_port >> 8) as u8,
|
||||
(relay_port & 0xff) as u8,
|
||||
])
|
||||
.is_err()
|
||||
{
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
let udp_rx = udp::PacketBuffer::new(
|
||||
vec![udp::PacketMetadata::EMPTY; 32],
|
||||
vec![0u8; SMOLTCP_UDP_BUF],
|
||||
);
|
||||
let udp_tx = udp::PacketBuffer::new(
|
||||
vec![udp::PacketMetadata::EMPTY; 32],
|
||||
vec![0u8; SMOLTCP_UDP_BUF],
|
||||
);
|
||||
let mut udp_socket = udp::Socket::new(udp_rx, udp_tx);
|
||||
let local_port = 20000 + (rand::random::<u16>() % 40000);
|
||||
if udp_socket.bind(local_port).is_err() {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Swap this connection's unused TCP socket for the UDP socket;
|
||||
// `smol_handle` now keys the UDP socket, so teardown is unchanged.
|
||||
sockets.remove(conn.smol_handle);
|
||||
conn.smol_handle = sockets.add(udp_socket);
|
||||
conn.udp = Some(UdpAssoc {
|
||||
relay,
|
||||
client_addr: None,
|
||||
});
|
||||
conn.socks_done = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
conn.dest_addr = Some(addr);
|
||||
|
||||
// Open smoltcp TCP socket to the destination
|
||||
@@ -641,6 +783,62 @@ impl WireGuardSocks5Server {
|
||||
|
||||
conn.connecting = true;
|
||||
}
|
||||
} else if conn.udp.is_some() {
|
||||
// === UDP ASSOCIATE relay ===
|
||||
// The association lives only while the TCP control connection is
|
||||
// open (RFC 1928 §6); tear down when the browser closes it.
|
||||
let mut probe = [0u8; 1];
|
||||
match conn.tcp_stream.try_read(&mut probe) {
|
||||
Ok(0) => {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
Ok(_) => {} // ignore any data on the control channel
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(_) => {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let handle = conn.smol_handle;
|
||||
let Some(udp) = conn.udp.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Browser → tunnel: strip the §7 header and forward the payload.
|
||||
let mut dbuf = [0u8; SMOLTCP_UDP_BUF];
|
||||
loop {
|
||||
match udp.relay.recv_from(&mut dbuf) {
|
||||
Ok((n, src)) => {
|
||||
udp.client_addr = Some(src);
|
||||
if let Some((dst, off)) = parse_udp_datagram(&dbuf[..n]) {
|
||||
let socket = sockets.get_mut::<udp::Socket>(handle);
|
||||
if socket.can_send() {
|
||||
let _ = socket.send_slice(&dbuf[off..n], dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Tunnel → browser: wrap each datagram in a §7 header and relay back.
|
||||
loop {
|
||||
let socket = sockets.get_mut::<udp::Socket>(handle);
|
||||
if !socket.can_recv() {
|
||||
break;
|
||||
}
|
||||
let (payload, src) = match socket.recv() {
|
||||
Ok((data, meta)) => (data.to_vec(), meta.endpoint),
|
||||
Err(_) => break,
|
||||
};
|
||||
if let Some(client) = udp.client_addr {
|
||||
let resp = build_udp_datagram(src, &payload);
|
||||
let _ = udp.relay.send_to(&resp, client);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Data relay between SOCKS5 client and smoltcp socket
|
||||
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
|
||||
|
||||
@@ -728,9 +728,21 @@ impl WayfernManager {
|
||||
}
|
||||
|
||||
if let Some(proxy) = proxy_url {
|
||||
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
|
||||
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
|
||||
// PROXY is HTTP CONNECT (TCP only). The host:port is the same either way.
|
||||
let (pac_directive, host_port) = if let Some(rest) = proxy.strip_prefix("socks5://") {
|
||||
("SOCKS5", rest)
|
||||
} else {
|
||||
(
|
||||
"PROXY",
|
||||
proxy
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("https://"),
|
||||
)
|
||||
};
|
||||
let pac_data = format!(
|
||||
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"PROXY {}\";}}",
|
||||
proxy.trim_start_matches("http://").trim_start_matches("https://")
|
||||
"data:application/x-ns-proxy-autoconfig,function FindProxyForURL(url,host){{return \"{pac_directive} {host_port}\";}}",
|
||||
);
|
||||
args.push(format!("--proxy-pac-url={pac_data}"));
|
||||
args.push("--dns-prefetch-disable".to_string());
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getEntitlements } from "@/lib/entitlements";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SyncSettings } from "@/types";
|
||||
|
||||
interface AccountPageProps {
|
||||
@@ -197,8 +198,13 @@ export function AccountPage({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl flex flex-col">
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<DialogContent className="max-w-2xl max-h-[calc(100vh-4rem)] flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-4 p-4 overflow-y-auto flex-1 min-h-0",
|
||||
subPage && "w-full max-w-2xl mx-auto",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs defaultValue="account">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="account">
|
||||
|
||||
@@ -63,11 +63,11 @@ export function BandwidthMiniChart({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors min-w-[120px] border-none bg-transparent",
|
||||
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors w-full min-w-0 border-none bg-transparent",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 h-3 pointer-events-none">
|
||||
<div className="flex-1 min-w-0 h-3 pointer-events-none">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
@@ -111,7 +111,7 @@ export function BandwidthMiniChart({
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap min-w-[60px] text-right">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap shrink-0 min-w-[60px] text-right">
|
||||
{formatBytes(currentBandwidth)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -149,7 +149,7 @@ export function CamoufoxConfigDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-3xl h-[min(85vh,52rem)] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{isRunning
|
||||
@@ -164,7 +164,7 @@ export function CamoufoxConfigDialog({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 h-[300px]">
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="py-4">
|
||||
{profile.browser === "wayfern" ? (
|
||||
<WayfernConfigForm
|
||||
|
||||
@@ -77,7 +77,7 @@ export function CloneProfileDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -157,7 +157,7 @@ export function CommandPalette({
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={onOpenChange} filter={fuzzyFilter}>
|
||||
<CommandInput placeholder={t("commandPalette.placeholder")} />
|
||||
<CommandList>
|
||||
<CommandList className="max-h-[min(60vh,480px)]">
|
||||
<CommandEmpty>{t("commandPalette.empty")}</CommandEmpty>
|
||||
|
||||
<CommandGroup heading={t("commandPalette.groups.navigation")}>
|
||||
@@ -205,7 +205,7 @@ export function CommandPalette({
|
||||
}}
|
||||
>
|
||||
<LuCircleStop />
|
||||
<span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("commandPalette.actions.stopProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
@@ -221,7 +221,7 @@ export function CommandPalette({
|
||||
}}
|
||||
>
|
||||
<LuPlay />
|
||||
<span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("commandPalette.actions.launchProfile", {
|
||||
name: p.name,
|
||||
})}
|
||||
@@ -239,7 +239,7 @@ export function CommandPalette({
|
||||
}}
|
||||
>
|
||||
<LuInfo />
|
||||
<span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t("commandPalette.actions.profileInfo", { name: p.name })}
|
||||
</span>
|
||||
</CommandItem>
|
||||
|
||||
@@ -332,7 +332,7 @@ export function CookieCopyDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuCookie className="size-5" />
|
||||
@@ -463,7 +463,7 @@ export function CookieCopyDialog({
|
||||
: t("cookies.copy.noFound")}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredDomains.map((domain) => (
|
||||
<DomainRow
|
||||
@@ -559,7 +559,7 @@ function DomainRow({
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 text-left bg-transparent border-none cursor-pointer"
|
||||
className="flex items-center gap-1 flex-1 min-w-0 text-left bg-transparent border-none cursor-pointer"
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
@@ -569,8 +569,8 @@ function DomainRow({
|
||||
) : (
|
||||
<LuChevronRight className="size-4" />
|
||||
)}
|
||||
<span className="font-medium">{domain.domain}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="font-medium truncate">{domain.domain}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
({domain.cookie_count})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -390,7 +390,7 @@ export function CookieManagementDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-[min(44rem,calc(100%-4rem))]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("cookies.management.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
@@ -563,7 +563,7 @@ export function CookieManagementDialog({
|
||||
{t("cookies.management.noCookies")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea className="h-[200px]">
|
||||
<FadingScrollArea className="h-[clamp(140px,30vh,420px)]">
|
||||
<div className="p-2 space-y-1">
|
||||
{exportCookieData.domains.map((domain) => (
|
||||
<ExportDomainRow
|
||||
|
||||
@@ -534,7 +534,7 @@ export function CreateProfileDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-[380px] max-w-[380px] max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>
|
||||
{currentStep === "browser-selection"
|
||||
@@ -557,7 +557,7 @@ export function CreateProfileDialog({
|
||||
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<div className="flex flex-col justify-center items-center w-full">
|
||||
<div className="py-4 space-y-6 w-full max-w-md">
|
||||
<div className="py-4 space-y-6 w-full">
|
||||
{currentStep === "browser-selection" ? (
|
||||
<>
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
|
||||
@@ -201,7 +201,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="flex items-start p-3 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -65,7 +65,7 @@ function DataTableActionBar<TData>({
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
|
||||
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -57,7 +57,10 @@ export function DeleteConfirmationDialog({
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
const displayName = profile ? profile.name : id;
|
||||
return (
|
||||
<li key={id} className="text-sm text-muted-foreground">
|
||||
<li
|
||||
key={id}
|
||||
className="text-sm text-muted-foreground truncate"
|
||||
>
|
||||
• {displayName}
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -136,10 +136,10 @@ export function DeleteGroupDialog({
|
||||
count: associatedProfiles.length,
|
||||
})}
|
||||
</Label>
|
||||
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
||||
<ScrollArea className="max-h-[min(8rem,25vh)] overflow-y-auto w-full border rounded-md p-3">
|
||||
<div className="space-y-1">
|
||||
{associatedProfiles.map((profile) => (
|
||||
<div key={profile.id} className="text-sm">
|
||||
<div key={profile.id} className="text-sm truncate">
|
||||
• {profile.name}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -87,7 +87,7 @@ export function DnsBlocklistDialog({
|
||||
{t("dnsBlocklist.settingsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 overflow-y-auto min-h-0 max-h-[40vh]">
|
||||
{statuses.map((status) => (
|
||||
<div
|
||||
key={status.level}
|
||||
|
||||
@@ -110,7 +110,7 @@ export function ExtensionGroupAssignmentDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.assignTitle")}:</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<div className="p-3 bg-muted rounded-md max-h-[min(8rem,20vh)] overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
const profile = profiles.find(
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Extension, ExtensionGroup } from "@/types";
|
||||
import { DeleteConfirmationDialog } from "./delete-confirmation-dialog";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -770,6 +771,7 @@ export function ExtensionManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "compat",
|
||||
size: 56,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
@@ -821,6 +823,7 @@ export function ExtensionManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
@@ -942,6 +945,7 @@ export function ExtensionManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "extensions",
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
@@ -952,7 +956,7 @@ export function ExtensionManagementDialog({
|
||||
const visibleExts = groupExts.slice(0, MAX_VISIBLE_ICONS);
|
||||
const overflowCount = groupExts.length - MAX_VISIBLE_ICONS;
|
||||
return (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex items-center gap-1 min-w-0">
|
||||
{visibleExts.map((ext) => (
|
||||
<Tooltip key={ext.id}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -985,7 +989,7 @@ export function ExtensionManagementDialog({
|
||||
</Tooltip>
|
||||
)}
|
||||
{groupExts.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-xs text-muted-foreground truncate min-w-0">
|
||||
{t("extensions.noExtensionsInGroup")}
|
||||
</span>
|
||||
)}
|
||||
@@ -1043,6 +1047,7 @@ export function ExtensionManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
@@ -1111,7 +1116,7 @@ export function ExtensionManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-[min(80rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
@@ -1125,7 +1130,7 @@ export function ExtensionManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div className="@container relative w-full flex-1 min-h-0 flex flex-col">
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
@@ -1150,7 +1155,7 @@ export function ExtensionManagementDialog({
|
||||
onValueChange={(v) => setActiveTab(v as "extensions" | "groups")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 shrink-0">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger
|
||||
value="extensions"
|
||||
@@ -1170,27 +1175,45 @@ export function ExtensionManagementDialog({
|
||||
</AnimatedTabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === "extensions" && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={limitedMode}
|
||||
onClick={() =>
|
||||
document.getElementById("ext-file-input")?.click()
|
||||
}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
{t("extensions.upload")}
|
||||
</RippleButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={limitedMode}
|
||||
onClick={() =>
|
||||
document.getElementById("ext-file-input")?.click()
|
||||
}
|
||||
aria-label={t("extensions.upload")}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("extensions.upload")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("extensions.upload")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{activeTab === "groups" && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
disabled={limitedMode}
|
||||
onClick={() => setShowCreateGroup(true)}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
{t("extensions.newGroup")}
|
||||
</RippleButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
disabled={limitedMode}
|
||||
onClick={() => setShowCreateGroup(true)}
|
||||
aria-label={t("extensions.newGroup")}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("extensions.newGroup")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("extensions.newGroup")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1267,14 +1290,20 @@ export function ExtensionManagementDialog({
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
selectedExtensions.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{extTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
@@ -1282,10 +1311,14 @@ export function ExtensionManagementDialog({
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
header.column.id === "name"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -1308,10 +1341,14 @@ export function ExtensionManagementDialog({
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
cell.column.id === "name"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
@@ -1374,14 +1411,20 @@ export function ExtensionManagementDialog({
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
selectedGroups.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{groupTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
@@ -1389,10 +1432,14 @@ export function ExtensionManagementDialog({
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
header.column.id === "name"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -1415,10 +1462,14 @@ export function ExtensionManagementDialog({
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
cell.column.id === "name"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
@@ -1515,7 +1566,7 @@ export function ExtensionManagementDialog({
|
||||
{t("extensions.noExtensionsInGroup")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
<div className="space-y-1 max-h-[min(40vh,320px)] overflow-y-auto">
|
||||
{editGroupExtensionIds.map((extId) => {
|
||||
const ext = extensions.find((e) => e.id === extId);
|
||||
if (!ext) return null;
|
||||
@@ -1612,7 +1663,7 @@ export function ExtensionManagementDialog({
|
||||
<Label className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||
{t("extensions.metadata")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
|
||||
{editingExtension.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
@@ -1660,7 +1711,7 @@ export function ExtensionManagementDialog({
|
||||
href={editingExtension.homepage_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1 truncate"
|
||||
className="text-primary hover:underline flex items-center gap-1 min-w-0"
|
||||
>
|
||||
<span className="truncate">
|
||||
{editingExtension.homepage_url}
|
||||
|
||||
@@ -134,7 +134,7 @@ export function GroupAssignmentDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("groupAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<div className="p-3 bg-muted rounded-md max-h-[min(8rem,20vh)] overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
// Find the profile name for display
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
|
||||
interface GroupBadgesProps {
|
||||
selectedGroupId: string | null;
|
||||
onGroupSelect: (groupId: string) => void;
|
||||
refreshTrigger?: number;
|
||||
groups: GroupWithCount[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function GroupBadges({
|
||||
selectedGroupId,
|
||||
onGroupSelect,
|
||||
groups,
|
||||
isLoading,
|
||||
}: GroupBadgesProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [showLeftFade, setShowLeftFade] = useState(false);
|
||||
const [showRightFade, setShowRightFade] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragStartRef = useRef<{ x: number; scrollLeft: number } | null>(null);
|
||||
const hasMovedRef = useRef(false);
|
||||
const clickBlockedRef = useRef(false);
|
||||
|
||||
const checkScrollPosition = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = container;
|
||||
setShowLeftFade(scrollLeft > 0);
|
||||
setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1);
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
dragStartRef.current = {
|
||||
x: e.clientX,
|
||||
scrollLeft: container.scrollLeft,
|
||||
};
|
||||
hasMovedRef.current = false;
|
||||
setIsDragging(true);
|
||||
container.style.cursor = "grabbing";
|
||||
container.style.userSelect = "none";
|
||||
}, []);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging || !dragStartRef.current) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const deltaX = e.clientX - dragStartRef.current.x;
|
||||
const distance = Math.abs(deltaX);
|
||||
|
||||
if (distance > 5) {
|
||||
hasMovedRef.current = true;
|
||||
}
|
||||
|
||||
container.scrollLeft = dragStartRef.current.scrollLeft - deltaX;
|
||||
checkScrollPosition();
|
||||
},
|
||||
[isDragging, checkScrollPosition],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.style.cursor = "";
|
||||
container.style.userSelect = "";
|
||||
}
|
||||
|
||||
clickBlockedRef.current = hasMovedRef.current;
|
||||
setIsDragging(false);
|
||||
dragStartRef.current = null;
|
||||
|
||||
setTimeout(() => {
|
||||
hasMovedRef.current = false;
|
||||
clickBlockedRef.current = false;
|
||||
}, 100);
|
||||
}, [isDragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
checkScrollPosition();
|
||||
container.addEventListener("scroll", checkScrollPosition);
|
||||
const resizeObserver = new ResizeObserver(checkScrollPosition);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", checkScrollPosition);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [checkScrollPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (groups.length === 0) {
|
||||
setShowLeftFade(false);
|
||||
setShowRightFade(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
checkScrollPosition();
|
||||
});
|
||||
});
|
||||
}, [groups, checkScrollPosition]);
|
||||
|
||||
if (isLoading && !groups.length) {
|
||||
return (
|
||||
<div className="flex gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
|
||||
{t("groups.loading")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mb-4">
|
||||
{showLeftFade && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-8 bg-linear-to-r from-background to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
{showRightFade && (
|
||||
<div className="absolute right-0 top-0 bottom-0 w-8 bg-linear-to-l from-background to-transparent pointer-events-none z-10" />
|
||||
)}
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
role="region"
|
||||
aria-label={t("groups.profileGroupsAriaLabel")}
|
||||
className={`flex gap-2 overflow-x-auto pb-2 -mb-2 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
onScroll={checkScrollPosition}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{groups.map((group) => (
|
||||
<Badge
|
||||
key={group.id}
|
||||
variant={selectedGroupId === group.id ? "default" : "secondary"}
|
||||
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80 shrink-0"
|
||||
onClick={(e) => {
|
||||
if (hasMovedRef.current || clickBlockedRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onGroupSelect(
|
||||
selectedGroupId === group.id ? "default" : group.id,
|
||||
);
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
if (isDragging) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
|
||||
{group.count}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from "@/components/ui/tooltip";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GroupWithCount, ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -345,7 +346,7 @@ export function GroupManagementDialog({
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<div className="flex items-center gap-2 font-medium min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -358,8 +359,8 @@ export function GroupManagementDialog({
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<LuFolder className="size-4 text-muted-foreground" />
|
||||
{group.name}
|
||||
<LuFolder className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{group.name}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -552,7 +553,7 @@ export function GroupManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="max-w-[min(60rem,calc(100%-4rem))] max-h-[90vh] flex flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("groups.management")}</DialogTitle>
|
||||
@@ -562,7 +563,7 @@ export function GroupManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="w-full flex flex-col gap-4 flex-1 min-h-0">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h2 className="text-base font-semibold">
|
||||
@@ -601,14 +602,20 @@ export function GroupManagementDialog({
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
selectedGroupsForBulk.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
@@ -616,10 +623,14 @@ export function GroupManagementDialog({
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
header.column.id === "name"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
@@ -642,10 +653,14 @@ export function GroupManagementDialog({
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
width:
|
||||
cell.column.id === "name"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
|
||||
@@ -131,6 +131,16 @@ const HomeHeader = ({
|
||||
[clearHold],
|
||||
);
|
||||
|
||||
const handleDoubleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isTextInputTarget(e.target)) return;
|
||||
if (e.target instanceof Element && e.target.closest("button")) return;
|
||||
clearHold();
|
||||
void getCurrentWindow().toggleMaximize();
|
||||
},
|
||||
[clearHold],
|
||||
);
|
||||
|
||||
// Horizontal scroll fades for the group filter strip — when the user
|
||||
// has more groups than fit, the right edge fades to hint at overflow.
|
||||
const groupsScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -156,20 +166,22 @@ const HomeHeader = ({
|
||||
const isWindows = platform === "windows";
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: titlebar drag surface; the interactive controls inside are real buttons/inputs
|
||||
<div
|
||||
ref={dragRootRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
"flex items-center gap-2 h-11 pl-3 border-b border-border bg-card select-none",
|
||||
// Windows: WindowDragArea renders two 44px native-style controls
|
||||
// (minimize + close) fixed at top-right with z-50, total 88px wide.
|
||||
// Reserve 100px on the right edge so the "+ New" button and search
|
||||
// input clear them with a few pixels of breathing room — issues
|
||||
// #358, #361, #362 all reported the same overlap before this fix.
|
||||
isWindows ? "pr-[100px]" : "pr-3",
|
||||
// Windows: WindowDragArea renders three 44px native-style controls
|
||||
// (minimize + maximize/restore + close) fixed at top-right with
|
||||
// z-50, total 132px wide. Reserve 144px on the right edge so the
|
||||
// "+ New" button and search input clear them with a few pixels of
|
||||
// breathing room and never sit underneath the controls.
|
||||
isWindows ? "pr-[144px]" : "pr-3",
|
||||
)}
|
||||
>
|
||||
{isMacOS && (
|
||||
@@ -248,6 +260,7 @@ const HomeHeader = ({
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
title={group.name}
|
||||
onClick={() => {
|
||||
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
||||
}}
|
||||
@@ -258,7 +271,7 @@ const HomeHeader = ({
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
<span className="max-w-40 truncate">{group.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{group.count}
|
||||
</span>
|
||||
@@ -297,7 +310,7 @@ const HomeHeader = ({
|
||||
onChange={(e) => {
|
||||
onSearchQueryChange(e.target.value);
|
||||
}}
|
||||
className="pr-7 pl-8 w-52 h-7 text-xs"
|
||||
className="pr-7 pl-8 w-36 min-[860px]:w-52 h-7 text-xs"
|
||||
/>
|
||||
<LuSearch className="absolute left-2.5 top-1/2 size-3.5 transform -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||
{searchQuery ? (
|
||||
|
||||
@@ -306,14 +306,19 @@ export function ImportProfileDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogContent className="max-w-[min(48rem,calc(100%-4rem))] max-h-[80vh] flex flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("importProfile.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-y-auto flex-1 space-y-6 min-h-0",
|
||||
subPage && "mx-auto w-full max-w-2xl",
|
||||
)}
|
||||
>
|
||||
{currentStep === "select" && (
|
||||
<AnimatedTabs
|
||||
value={importMode}
|
||||
@@ -409,7 +414,7 @@ export function ImportProfileDialog({
|
||||
|
||||
{selectedProfile && (
|
||||
<div className="p-3 rounded-lg bg-muted">
|
||||
<p className="text-sm">
|
||||
<p className="text-sm break-all">
|
||||
<span className="font-medium">
|
||||
{t("importProfile.pathLabel")}
|
||||
</span>{" "}
|
||||
@@ -513,7 +518,7 @@ export function ImportProfileDialog({
|
||||
<FaFolder className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-xs text-muted-foreground break-all">
|
||||
{t("importProfile.examplePaths")}
|
||||
<br />
|
||||
macOS: ~/Library/Application
|
||||
@@ -600,7 +605,9 @@ export function ImportProfileDialog({
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 flex gap-2 items-center justify-end",
|
||||
subPage ? "pt-2 border-t border-border" : undefined,
|
||||
subPage
|
||||
? "pt-2 border-t border-border mx-auto w-full max-w-2xl"
|
||||
: undefined,
|
||||
)}
|
||||
>
|
||||
{currentStep === "select" ? (
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||
|
||||
interface AppSettings {
|
||||
@@ -307,14 +308,19 @@ export function IntegrationsDialog({
|
||||
}}
|
||||
subPage={subPage}
|
||||
>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] my-8 flex flex-col">
|
||||
<DialogContent className="max-w-3xl max-h-[calc(100vh-5rem)] flex flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("integrations.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-y-auto flex-1 min-h-0",
|
||||
subPage && "w-full max-w-3xl mx-auto",
|
||||
)}
|
||||
>
|
||||
<AnimatedTabs key={initialTab} defaultValue={initialTab}>
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="api">
|
||||
@@ -327,7 +333,7 @@ export function IntegrationsDialog({
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="api"
|
||||
className="mt-4 flex flex-col gap-4"
|
||||
className="mt-4 flex flex-col gap-4 @container"
|
||||
>
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
@@ -364,7 +370,7 @@ export function IntegrationsDialog({
|
||||
|
||||
{settings.api_enabled && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 @2xl:grid-cols-2 gap-4">
|
||||
<div className="rounded-md border bg-card p-4 flex flex-col gap-2">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("integrations.apiPortLabel")}
|
||||
@@ -581,11 +587,11 @@ export function IntegrationsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-3 @container">
|
||||
<Label className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t("integrations.mcp.clientsLabel")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 @2xl:grid-cols-2 gap-3">
|
||||
{agents.map((agent) => {
|
||||
const busy = busyAgentIds.has(agent.id);
|
||||
return (
|
||||
|
||||
@@ -233,7 +233,7 @@ export function LocationProxyDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 overflow-y-auto min-h-0 max-h-[calc(100vh-16rem)] pr-1">
|
||||
{/* Country - always visible */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
|
||||
@@ -194,8 +194,16 @@ const MultipleSelector = React.forwardRef<
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [dropUp, setDropUp] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const updateDropUp = React.useCallback(() => {
|
||||
const rect = inputRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
setDropUp(spaceBelow < 240 && rect.top > spaceBelow);
|
||||
}, []);
|
||||
|
||||
const [selected, setSelected] = React.useState<Option[]>(value ?? []);
|
||||
const [options, setOptions] = React.useState<GroupOption>(
|
||||
transToGroupOption(arrayDefaultOptions, groupBy),
|
||||
@@ -203,6 +211,19 @@ const MultipleSelector = React.forwardRef<
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const debouncedSearchTerm = useDebounce(inputValue, delay ?? 500);
|
||||
|
||||
// Re-evaluate the flip while the list is open: selecting options grows
|
||||
// the badge row (moving the input down) and window resizes change the
|
||||
// space below — both can invalidate the side chosen on focus.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
void selected.length;
|
||||
updateDropUp();
|
||||
window.addEventListener("resize", updateDropUp);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateDropUp);
|
||||
};
|
||||
}, [open, selected.length, updateDropUp]);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -377,7 +398,7 @@ const MultipleSelector = React.forwardRef<
|
||||
commandProps?.onKeyDown?.(e);
|
||||
}}
|
||||
className={cn(
|
||||
"h-auto overflow-visible bg-transparent",
|
||||
"relative h-auto overflow-visible bg-transparent",
|
||||
commandProps?.className,
|
||||
)}
|
||||
shouldFilter={
|
||||
@@ -488,6 +509,7 @@ const MultipleSelector = React.forwardRef<
|
||||
inputProps?.onBlur?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
updateDropUp();
|
||||
setOpen(true);
|
||||
if (triggerSearchOnFocus && onSearch) {
|
||||
void onSearch(debouncedSearchTerm);
|
||||
@@ -511,9 +533,14 @@ const MultipleSelector = React.forwardRef<
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div>
|
||||
{open && hasAvailableOptions && (
|
||||
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
|
||||
<CommandList
|
||||
className={cn(
|
||||
"absolute z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in",
|
||||
dropUp ? "bottom-full mb-1" : "top-full mt-1",
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
loadingIndicator
|
||||
) : (
|
||||
@@ -527,7 +554,7 @@ const MultipleSelector = React.forwardRef<
|
||||
<CommandGroup
|
||||
key={key}
|
||||
heading={key}
|
||||
className="overflow-auto h-24"
|
||||
className="overflow-auto max-h-48"
|
||||
>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
type RowData,
|
||||
type RowSelectionState,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -81,7 +83,6 @@ import {
|
||||
isCrossOsProfile,
|
||||
} from "@/lib/browser-utils";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
@@ -105,6 +106,15 @@ import { TrafficDetailsDialog } from "./traffic-details-dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
// Emit no width for this column so table-fixed hands it all remaining
|
||||
// space. Checking columnDef.size alone can't express this: TanStack
|
||||
// resolves an unspecified size to its 150px default.
|
||||
flexWidth?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
// Stable table meta type to pass volatile state/handlers into TanStack Table without
|
||||
// causing column definitions to be recreated on every render.
|
||||
interface TableMeta {
|
||||
@@ -822,6 +832,96 @@ const NonHoverableTooltip = React.memo<{
|
||||
|
||||
NonHoverableTooltip.displayName = "NonHoverableTooltip";
|
||||
|
||||
// CSS-truncated text whose tooltip only appears when the text actually
|
||||
// overflows its column (measured on hover, so it tracks live resizes).
|
||||
const OverflowTooltipText = React.memo<{
|
||||
text: string;
|
||||
className?: string;
|
||||
}>(({ text, className }) => {
|
||||
const textRef = React.useRef<HTMLSpanElement | null>(null);
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
onOpenChange={(open) => {
|
||||
if (!open) return;
|
||||
const el = textRef.current;
|
||||
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn("block min-w-0 max-w-full truncate", className)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{isOverflowing && <TooltipContent>{text}</TooltipContent>}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
OverflowTooltipText.displayName = "OverflowTooltipText";
|
||||
|
||||
// Must be rendered inside a <Popover>; the tooltip shows the full assignment
|
||||
// name only when it is truncated in the cell.
|
||||
const ProxyCellTrigger = React.memo<{
|
||||
displayName: string;
|
||||
hasAssignment: boolean;
|
||||
vpnBadge: string | null;
|
||||
isDisabled: boolean;
|
||||
}>(({ displayName, hasAssignment, vpnBadge, isDisabled }) => {
|
||||
const textRef = React.useRef<HTMLSpanElement | null>(null);
|
||||
const [isOverflowing, setIsOverflowing] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
onOpenChange={(open) => {
|
||||
if (!open) return;
|
||||
const el = textRef.current;
|
||||
if (el) setIsOverflowing(el.scrollWidth > el.clientWidth);
|
||||
}}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-1 rounded min-w-0 max-w-full",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{vpnBadge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight shrink-0"
|
||||
>
|
||||
{vpnBadge}
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
ref={textRef}
|
||||
className={cn(
|
||||
"text-sm min-w-0 truncate",
|
||||
!hasAssignment && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{hasAssignment && isOverflowing && (
|
||||
<TooltipContent>{displayName}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
ProxyCellTrigger.displayName = "ProxyCellTrigger";
|
||||
|
||||
const NoteCell = React.memo<{
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
@@ -2276,7 +2376,9 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
size: 130,
|
||||
// The only column without a fixed width: table-fixed hands it all
|
||||
// remaining space as the window grows or shrinks.
|
||||
meta: { flexWidth: true },
|
||||
header: ({ column, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
return (
|
||||
@@ -2341,27 +2443,18 @@ export function ProfilesDataTable({
|
||||
meta.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
className="w-30 h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
|
||||
className="w-full min-w-0 max-w-full h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const display =
|
||||
name.length < 14 ? (
|
||||
<div className="font-medium text-left leading-none truncate">
|
||||
{name}
|
||||
</div>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="leading-none block truncate">
|
||||
{trimName(name, 14)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{name}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
const display = (
|
||||
<OverflowTooltipText
|
||||
text={name}
|
||||
className="font-medium text-left leading-none"
|
||||
/>
|
||||
);
|
||||
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isCrossOsBlocked = isCrossOs;
|
||||
@@ -2528,7 +2621,6 @@ export function ProfilesDataTable({
|
||||
? effectiveProxy.name
|
||||
: meta.t("profiles.table.notSelected");
|
||||
const vpnBadge = effectiveVpn ? "WG" : null;
|
||||
const tooltipText = hasAssignment ? displayName : null;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
||||
|
||||
@@ -2562,42 +2654,12 @@ export function ProfilesDataTable({
|
||||
meta.setOpenProxySelectorFor(open ? profile.id : null);
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"flex gap-2 items-center px-2 py-1 rounded",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed pointer-events-none"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{vpnBadge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight"
|
||||
>
|
||||
{vpnBadge}
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!hasAssignment && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{hasAssignment
|
||||
? trimName(displayName, 10)
|
||||
: displayName}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{tooltipText && (
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<ProxyCellTrigger
|
||||
displayName={displayName}
|
||||
hasAssignment={hasAssignment}
|
||||
vpnBadge={vpnBadge}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
|
||||
{!isDisabled && (
|
||||
<PopoverContent
|
||||
@@ -2861,15 +2923,29 @@ export function ProfilesDataTable({
|
||||
[t, setProfileForInfoDialog],
|
||||
);
|
||||
|
||||
// Low-priority columns leave the table as the container narrows (most
|
||||
// expendable first); their data stays reachable via the profile info
|
||||
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
|
||||
// Content columns grow proportionally with the container but never drop
|
||||
// below the compact-layout floor; the name column takes the remainder.
|
||||
// Computed in px from the observed container width because fixed table
|
||||
// layout ignores max()/calc() column widths.
|
||||
const [containerWidth, setContainerWidth] = React.useState(0);
|
||||
|
||||
const table = useReactTable({
|
||||
data: profiles,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
},
|
||||
onSortingChange: handleSortingChange,
|
||||
onRowSelectionChange: handleRowSelectionChange,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
enableRowSelection: (row) => {
|
||||
const profile = row.original;
|
||||
const isRunning =
|
||||
@@ -2885,9 +2961,50 @@ export function ProfilesDataTable({
|
||||
});
|
||||
|
||||
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const columnWidth = React.useCallback(
|
||||
(id: string, sizePx: number) => {
|
||||
const proportions: Record<string, { pct: number; floor: number }> = {
|
||||
tags: { pct: 0.12, floor: 100 },
|
||||
note: { pct: 0.1, floor: 80 },
|
||||
proxy: { pct: 0.13, floor: 110 },
|
||||
ext: { pct: 0.11, floor: 95 },
|
||||
dns: { pct: 0.11, floor: 95 },
|
||||
};
|
||||
const p = proportions[id];
|
||||
if (!p) return `${sizePx}px`;
|
||||
return `${Math.max(p.floor, Math.round(containerWidth * p.pct))}px`;
|
||||
},
|
||||
[containerWidth],
|
||||
);
|
||||
const sortedRows = table.getRowModel().rows;
|
||||
useScrollFade(scrollParentRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = scrollParentRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const w = el.clientWidth;
|
||||
setContainerWidth(Math.round(w / 8) * 8);
|
||||
setColumnVisibility((prev) => {
|
||||
const next: VisibilityState = {
|
||||
dns: w >= 768,
|
||||
ext: w >= 672,
|
||||
note: w >= 576,
|
||||
tags: w >= 512,
|
||||
};
|
||||
return Object.keys(next).every((k) => prev[k] === next[k])
|
||||
? prev
|
||||
: next;
|
||||
});
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Compact 36px row from the redesign spec; estimateSize must match the
|
||||
// actual rendered row height or virtualizer placement drifts under scroll.
|
||||
const ROW_HEIGHT = 36;
|
||||
@@ -2912,7 +3029,13 @@ export function ProfilesDataTable({
|
||||
<div className="relative flex-1 min-h-0 flex flex-col">
|
||||
<div
|
||||
ref={scrollParentRef}
|
||||
className="overflow-auto relative flex-1 min-h-0 scroll-fade"
|
||||
className={cn(
|
||||
"overflow-auto relative flex-1 min-h-0 scroll-fade",
|
||||
// Clearance for the floating selection action bar (bottom-6 +
|
||||
// ~46px tall) so the last rows can scroll out from behind it.
|
||||
// Same predicate DataTableActionBar uses for its visibility.
|
||||
table.getFilteredSelectedRowModel().rows.length > 0 && "pb-20",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
// Sticky table header is 32px tall (h-8); shift the top
|
||||
@@ -2922,7 +3045,7 @@ export function ProfilesDataTable({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table className="table-fixed">
|
||||
<Table className="table-fixed" containerClassName="overflow-visible">
|
||||
<TableHeader className="overflow-visible sticky top-0 z-10 bg-background [&_tr]:border-0">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow
|
||||
@@ -2934,9 +3057,12 @@ export function ProfilesDataTable({
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
width: header.column.columnDef.meta?.flexWidth
|
||||
? undefined
|
||||
: columnWidth(
|
||||
header.column.id,
|
||||
header.column.getSize(),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
@@ -2955,7 +3081,7 @@ export function ProfilesDataTable({
|
||||
{sortedRows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
colSpan={table.getVisibleLeafColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("profiles.table.empty")}
|
||||
@@ -2965,7 +3091,7 @@ export function ProfilesDataTable({
|
||||
<>
|
||||
{paddingTop > 0 && (
|
||||
<tr style={{ height: `${paddingTop}px` }}>
|
||||
<td colSpan={columns.length} />
|
||||
<td colSpan={table.getVisibleLeafColumns().length} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualRows.map((virtualRow) => {
|
||||
@@ -2997,9 +3123,12 @@ export function ProfilesDataTable({
|
||||
key={cell.id}
|
||||
className="overflow-visible py-0"
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
width: cell.column.columnDef.meta?.flexWidth
|
||||
? undefined
|
||||
: columnWidth(
|
||||
cell.column.id,
|
||||
cell.column.getSize(),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
@@ -3013,7 +3142,7 @@ export function ProfilesDataTable({
|
||||
})}
|
||||
{paddingBottom > 0 && (
|
||||
<tr style={{ height: `${paddingBottom}px` }}>
|
||||
<td colSpan={columns.length} />
|
||||
<td colSpan={table.getVisibleLeafColumns().length} />
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -503,7 +503,7 @@ export function ProfileInfoDialog({
|
||||
>
|
||||
<DialogContent
|
||||
hideClose
|
||||
className="sm:max-w-3xl w-[720px] max-w-[720px] h-[480px] max-h-[480px] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
className="max-w-[min(60rem,calc(100%-4rem))] h-[min(clamp(30rem,80vh,48rem),calc(100vh-3rem))] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
>
|
||||
{/* The dialog renders its own custom header, so the accessible title is
|
||||
visually hidden but present for screen readers (Radix requires it). */}
|
||||
|
||||
@@ -193,7 +193,7 @@ export function ProfilePasswordDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(titleKey)}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -180,7 +180,7 @@ export function ProfileSelectorDialog({
|
||||
successMessage={t("profileSelector.urlCopied")}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-2 text-sm break-all rounded bg-muted">
|
||||
<div className="p-2 text-sm break-all rounded bg-muted max-h-24 overflow-y-auto">
|
||||
{url}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,8 +172,8 @@ export function ProfileSyncDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogContent className="max-w-md flex flex-col overflow-hidden">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("sync.mode.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("sync.mode.description", {
|
||||
@@ -183,115 +183,117 @@ export function ProfileSyncDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isCheckingConfig ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
{!hasConfig && (
|
||||
<div className="p-3 text-sm rounded-md bg-muted">
|
||||
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("sync.mode.configureService")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasConfig && (
|
||||
<>
|
||||
<RadioGroup
|
||||
value={syncMode}
|
||||
onValueChange={handleModeChange}
|
||||
disabled={isSaving}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<div className="flex items-start gap-x-3">
|
||||
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
||||
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.disabled")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sync.mode.disabledDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-x-3">
|
||||
<RadioGroupItem value="Regular" id="sync-regular" />
|
||||
<Label htmlFor="sync-regular" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.regular")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sync.mode.regularDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-x-3">
|
||||
<RadioGroupItem
|
||||
value="Encrypted"
|
||||
id="sync-encrypted"
|
||||
disabled={!canUseEncryption}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="sync-encrypted"
|
||||
className={
|
||||
canUseEncryption
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.encrypted")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{canUseEncryption
|
||||
? t("sync.mode.encryptedDescription")
|
||||
: t("settings.encryption.requiresProOrOwner")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{syncMode === "Encrypted" &&
|
||||
!hasE2ePassword &&
|
||||
userChangedMode && (
|
||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||
{t("sync.mode.noPasswordWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sync.mode.lastSynced")}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant="outline">
|
||||
{formatLastSync(profile.last_sync)}
|
||||
</Badge>
|
||||
{isSyncEnabled(profile) && (
|
||||
<Badge
|
||||
variant={profile.last_sync ? "default" : "secondary"}
|
||||
>
|
||||
{profile.last_sync
|
||||
? t("common.status.synced")
|
||||
: t("common.status.pending")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{isCheckingConfig ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="size-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 py-4">
|
||||
{!hasConfig && (
|
||||
<div className="p-3 text-sm rounded-md bg-muted">
|
||||
<p className="mb-2">{t("sync.mode.notConfigured")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t("sync.mode.configureService")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{hasConfig && (
|
||||
<>
|
||||
<RadioGroup
|
||||
value={syncMode}
|
||||
onValueChange={handleModeChange}
|
||||
disabled={isSaving}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<div className="flex items-start gap-x-3">
|
||||
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
||||
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.disabled")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sync.mode.disabledDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-x-3">
|
||||
<RadioGroupItem value="Regular" id="sync-regular" />
|
||||
<Label htmlFor="sync-regular" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.regular")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("sync.mode.regularDescription")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-x-3">
|
||||
<RadioGroupItem
|
||||
value="Encrypted"
|
||||
id="sync-encrypted"
|
||||
disabled={!canUseEncryption}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="sync-encrypted"
|
||||
className={
|
||||
canUseEncryption
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.encrypted")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{canUseEncryption
|
||||
? t("sync.mode.encryptedDescription")
|
||||
: t("settings.encryption.requiresProOrOwner")}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{syncMode === "Encrypted" &&
|
||||
!hasE2ePassword &&
|
||||
userChangedMode && (
|
||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||
{t("sync.mode.noPasswordWarning")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("sync.mode.lastSynced")}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant="outline">
|
||||
{formatLastSync(profile.last_sync)}
|
||||
</Badge>
|
||||
{isSyncEnabled(profile) && (
|
||||
<Badge
|
||||
variant={profile.last_sync ? "default" : "secondary"}
|
||||
>
|
||||
{profile.last_sync
|
||||
? t("common.status.synced")
|
||||
: t("common.status.pending")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -157,7 +157,7 @@ export function ProxyAssignmentDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("proxyAssignment.selectedProfilesLabel")}</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<div className="p-3 bg-muted rounded-md max-h-[min(8rem,20vh)] overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
const profile = profiles.find(
|
||||
@@ -206,7 +206,7 @@ export function ProxyAssignmentDialog({
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
id={proxyListboxId}
|
||||
className="w-[240px] p-0"
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
|
||||
@@ -90,7 +90,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.exportDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -125,7 +125,7 @@ export function ProxyExportDialog({ isOpen, onClose }: ProxyExportDialogProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("proxies.exportDialog.preview")}</Label>
|
||||
<ScrollArea className="h-[200px] border rounded-md bg-muted/30">
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] border rounded-md bg-muted/30">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-full p-4 text-sm text-muted-foreground">
|
||||
{t("common.buttons.loading")}
|
||||
|
||||
@@ -158,7 +158,7 @@ export function ProxyFormDialog({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-4 py-4 @container">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-name">{t("proxies.form.name")}</Label>
|
||||
<Input
|
||||
@@ -228,12 +228,12 @@ export function ProxyFormDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 @sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-username">
|
||||
{form.proxy_type === "ss"
|
||||
? t("proxies.form.cipher")
|
||||
: `${t("proxies.form.username")} (${t("proxies.form.usernamePlaceholder")})`}
|
||||
: t("proxies.form.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-username"
|
||||
@@ -252,9 +252,7 @@ export function ProxyFormDialog({
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-password">
|
||||
{form.proxy_type === "ss"
|
||||
? t("proxies.form.password")
|
||||
: `${t("proxies.form.password")} (${t("proxies.form.passwordPlaceholder")})`}
|
||||
{t("proxies.form.password")}
|
||||
</Label>
|
||||
<Input
|
||||
id="proxy-password"
|
||||
|
||||
@@ -280,7 +280,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.importDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -376,12 +376,12 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<ScrollArea className="h-[200px] border rounded-md">
|
||||
<ScrollArea className="h-[clamp(120px,30vh,400px)] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
{parsedProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`${proxy.original_line}-${i}`}
|
||||
className="text-xs font-mono p-2 bg-muted/30 rounded"
|
||||
className="text-xs font-mono p-2 bg-muted/30 rounded break-all"
|
||||
>
|
||||
<span className="text-primary">
|
||||
{proxy.proxy_type}://
|
||||
@@ -407,14 +407,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("proxies.importDialog.ambiguousIntro")}
|
||||
</p>
|
||||
<ScrollArea className="h-[250px] border rounded-md">
|
||||
<ScrollArea className="h-[clamp(150px,35vh,450px)] border rounded-md">
|
||||
<div className="p-3 space-y-4">
|
||||
{ambiguousProxies.map((proxy, i) => (
|
||||
<div
|
||||
key={`${proxy.line}-${i}`}
|
||||
className="space-y-2 pb-3 border-b last:border-0"
|
||||
>
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded block">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded block break-all">
|
||||
{proxy.line}
|
||||
</code>
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -504,6 +504,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 28,
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
@@ -551,11 +552,14 @@ export function ProxyManagementDialog({
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
<span className="font-medium block truncate">
|
||||
{row.original.name}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
size: 96,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.protocolCol"),
|
||||
cell: ({ row }) => (
|
||||
@@ -564,8 +568,20 @@ export function ProxyManagementDialog({
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "hostPort",
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.hostPort"),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground block truncate">
|
||||
{row.original.proxy_settings.host}:
|
||||
{row.original.proxy_settings.port}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.usage"),
|
||||
cell: ({ row }) => (
|
||||
@@ -574,6 +590,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
size: 96,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.syncCol"),
|
||||
cell: ({ row }) => {
|
||||
@@ -607,6 +624,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 144,
|
||||
enableSorting: false,
|
||||
header: () => t("common.labels.actions"),
|
||||
cell: ({ row }) => {
|
||||
@@ -775,7 +793,7 @@ export function ProxyManagementDialog({
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<div className="flex items-center gap-2 font-medium min-w-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -788,19 +806,21 @@ export function ProxyManagementDialog({
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{vpn.name}
|
||||
<span className="truncate">{vpn.name}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
size: 96,
|
||||
enableSorting: false,
|
||||
header: () => t("common.labels.type"),
|
||||
cell: () => <Badge variant="outline">WG</Badge>,
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
size: 80,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.usage"),
|
||||
cell: ({ row }) => (
|
||||
@@ -809,6 +829,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "sync",
|
||||
size: 96,
|
||||
enableSorting: false,
|
||||
header: () => t("proxies.management.syncCol"),
|
||||
cell: ({ row }) => {
|
||||
@@ -842,6 +863,7 @@ export function ProxyManagementDialog({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 144,
|
||||
enableSorting: false,
|
||||
header: () => t("common.labels.actions"),
|
||||
cell: ({ row }) => {
|
||||
@@ -1068,7 +1090,7 @@ export function ProxyManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="max-w-[min(80rem,calc(100%-4rem))] max-h-[85vh] flex flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("proxies.management.title")}</DialogTitle>
|
||||
@@ -1078,251 +1100,355 @@ export function ProxyManagementDialog({
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<AnimatedTabs
|
||||
key={initialTab}
|
||||
defaultValue={initialTab}
|
||||
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 shrink-0">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="proxies">
|
||||
<span>{t("proxies.management.tabProxies")}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{storedProxies.length}
|
||||
</span>
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="vpns">
|
||||
<span>{t("proxies.management.tabVpns")}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{vpnConfigs.length}
|
||||
</span>
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === "proxies" && (
|
||||
<>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowExportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="size-4" />
|
||||
{t("common.buttons.export")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
{t("proxies.management.newProxy")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "vpns" && (
|
||||
<>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowVpnImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
{t("common.buttons.import")}
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
{t("proxies.management.newVpn")}
|
||||
</RippleButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="proxies"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
<div className="@container w-full flex-1 min-h-0 flex flex-col">
|
||||
<AnimatedTabs
|
||||
key={initialTab}
|
||||
defaultValue={initialTab}
|
||||
onValueChange={(v) => setActiveTab(v as "proxies" | "vpns")}
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("proxies.management.loading")}
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("proxies.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{proxiesTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id !== "name" &&
|
||||
header.column.id !== "select" &&
|
||||
"whitespace-nowrap w-px",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{proxiesTable.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 shrink-0">
|
||||
<AnimatedTabsList>
|
||||
<AnimatedTabsTrigger value="proxies">
|
||||
<span>{t("proxies.management.tabProxies")}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{storedProxies.length}
|
||||
</span>
|
||||
</AnimatedTabsTrigger>
|
||||
<AnimatedTabsTrigger value="vpns">
|
||||
<span>{t("proxies.management.tabVpns")}</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{vpnConfigs.length}
|
||||
</span>
|
||||
</AnimatedTabsTrigger>
|
||||
</AnimatedTabsList>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeTab === "proxies" && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
aria-label={t("common.buttons.import")}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FadingScrollArea>
|
||||
)}
|
||||
<LuUpload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("common.buttons.import")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.buttons.import")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowExportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
aria-label={t("common.buttons.export")}
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("common.buttons.export")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.buttons.export")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
aria-label={t("proxies.management.newProxy")}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("proxies.management.newProxy")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("proxies.management.newProxy")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{activeTab === "vpns" && (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowVpnImportDialog(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
aria-label={t("common.buttons.import")}
|
||||
>
|
||||
<LuUpload className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("common.buttons.import")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("common.buttons.import")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
aria-label={t("proxies.management.newVpn")}
|
||||
>
|
||||
<GoPlus className="size-4" />
|
||||
<span className="hidden @2xl:inline">
|
||||
{t("proxies.management.newVpn")}
|
||||
</span>
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("proxies.management.newVpn")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="vpns"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.management.loading")}
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className="flex-1 min-h-0"
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table className="w-full">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{vpnsTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
className={cn(
|
||||
header.column.id !== "name" &&
|
||||
header.column.id !== "select" &&
|
||||
"whitespace-nowrap w-px",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnsTable.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FadingScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
<AnimatedTabsContent
|
||||
value="proxies"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("proxies.management.loading")}
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("proxies.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
selectedProxies.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{proxiesTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width:
|
||||
header.column.id === "name" ||
|
||||
header.column.id === "hostPort"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
// name and hostPort emit no width, so
|
||||
// fixed layout splits the remaining
|
||||
// space evenly between them (hostPort
|
||||
// hides below @2xl, leaving name all
|
||||
// of it).
|
||||
header.column.id === "name" && "max-w-0",
|
||||
header.column.id === "hostPort" &&
|
||||
"hidden @2xl:table-cell max-w-0",
|
||||
(header.column.id === "protocol" ||
|
||||
header.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{proxiesTable.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width:
|
||||
cell.column.id === "name" ||
|
||||
cell.column.id === "hostPort"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
cell.column.id === "hostPort" &&
|
||||
"hidden @2xl:table-cell max-w-0",
|
||||
(cell.column.id === "protocol" ||
|
||||
cell.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FadingScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
|
||||
<AnimatedTabsContent
|
||||
value="vpns"
|
||||
className="mt-4 flex-1 min-h-0 data-[state=active]:flex flex-col"
|
||||
>
|
||||
<div className="flex flex-col gap-4 flex-1 min-h-0">
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.management.loading")}
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("vpns.management.noneCreated")}
|
||||
</div>
|
||||
) : (
|
||||
<FadingScrollArea
|
||||
className={cn(
|
||||
"flex-1 min-h-0",
|
||||
selectedVpns.length > 0 && "pb-16",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--scroll-fade-top-offset": "32px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<Table
|
||||
className="w-full table-fixed"
|
||||
containerClassName="overflow-visible"
|
||||
>
|
||||
<TableHeader className="sticky top-0 z-10 bg-background">
|
||||
{vpnsTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width:
|
||||
header.column.id === "name" ||
|
||||
header.column.id === "hostPort"
|
||||
? undefined
|
||||
: `${header.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
// name and hostPort emit no width, so
|
||||
// fixed layout splits the remaining
|
||||
// space evenly between them (hostPort
|
||||
// hides below @2xl, leaving name all
|
||||
// of it).
|
||||
header.column.id === "name" && "max-w-0",
|
||||
header.column.id === "hostPort" &&
|
||||
"hidden @2xl:table-cell max-w-0",
|
||||
(header.column.id === "protocol" ||
|
||||
header.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnsTable.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
width:
|
||||
cell.column.id === "name" ||
|
||||
cell.column.id === "hostPort"
|
||||
? undefined
|
||||
: `${cell.column.getSize()}px`,
|
||||
}}
|
||||
className={cn(
|
||||
cell.column.id === "name" && "max-w-0",
|
||||
cell.column.id === "hostPort" &&
|
||||
"hidden @2xl:table-cell max-w-0",
|
||||
(cell.column.id === "protocol" ||
|
||||
cell.column.id === "type") &&
|
||||
"hidden @2xl:table-cell",
|
||||
)}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</FadingScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</AnimatedTabsContent>
|
||||
</AnimatedTabs>
|
||||
</div>
|
||||
|
||||
{!subPage && (
|
||||
<DialogFooter>
|
||||
|
||||
+43
-39
@@ -74,8 +74,6 @@ function useLogoEasterEgg({
|
||||
const rect = el.getBoundingClientRect();
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
const floorY = window.innerHeight;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
@@ -99,6 +97,10 @@ function useLogoEasterEgg({
|
||||
const dt = Math.min((time - lastTime) / 1000, 0.05);
|
||||
lastTime = time;
|
||||
|
||||
// Read live so a mid-animation window resize moves the floor/wall.
|
||||
const floorY = window.innerHeight;
|
||||
const rightWall = window.innerWidth;
|
||||
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
@@ -294,7 +296,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
ref={logoRef}
|
||||
type="button"
|
||||
aria-label={t("header.donutLogo")}
|
||||
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent"
|
||||
className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent shrink-0"
|
||||
onClick={handleClick}
|
||||
onPointerDown={() => {
|
||||
setIsPressed(true);
|
||||
@@ -331,43 +333,45 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="size-7" />
|
||||
<div className="size-7 shrink-0" />
|
||||
)}
|
||||
|
||||
<div className="w-5 h-px bg-border my-1" />
|
||||
<div className="w-5 h-px bg-border my-1 shrink-0" />
|
||||
|
||||
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
|
||||
const active = currentPage === page;
|
||||
return (
|
||||
<Tooltip key={page} delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNavigate(page);
|
||||
}}
|
||||
aria-label={t(labelKey)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
|
||||
active
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-col items-center gap-1 w-full min-h-0 overflow-y-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
|
||||
const active = currentPage === page;
|
||||
return (
|
||||
<Tooltip key={page} delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onNavigate(page);
|
||||
}}
|
||||
aria-label={t(labelKey)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||
active
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="absolute left-[-7px] top-1.5 bottom-1.5 w-[2px] rounded-full bg-foreground"
|
||||
/>
|
||||
)}
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
@@ -381,7 +385,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
aria-label={t("rail.more.label")}
|
||||
aria-expanded={moreOpen}
|
||||
className={cn(
|
||||
"grid place-items-center size-7 rounded-md transition-colors duration-100",
|
||||
"grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||
moreOpen
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
@@ -403,7 +407,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
|
||||
aria-label={t("rail.settings")}
|
||||
aria-current={currentPage === "settings" ? "page" : undefined}
|
||||
className={cn(
|
||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100",
|
||||
"relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0",
|
||||
currentPage === "settings"
|
||||
? "text-foreground bg-accent"
|
||||
: "text-muted-foreground hover:text-card-foreground hover:bg-accent/50",
|
||||
|
||||
@@ -633,7 +633,7 @@ export function SettingsDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose} subPage={subPage}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogContent className="max-w-md max-h-[calc(100vh-5rem)] flex flex-col">
|
||||
{!subPage && (
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("settings.title")}</DialogTitle>
|
||||
@@ -643,7 +643,7 @@ export function SettingsDialog({
|
||||
<div
|
||||
className={cn(
|
||||
"grid overflow-y-auto flex-1 gap-6 min-h-0",
|
||||
subPage ? "py-2" : "py-4",
|
||||
subPage ? "py-2 w-full max-w-2xl mx-auto" : "py-4",
|
||||
)}
|
||||
>
|
||||
{/* Appearance Section */}
|
||||
@@ -748,7 +748,7 @@ export function SettingsDialog({
|
||||
<div className="text-sm font-medium">
|
||||
{t("settings.appearance.customColors")}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(4rem,1fr))] gap-3">
|
||||
{THEME_VARIABLES.map(({ key, label }) => {
|
||||
const colorValue =
|
||||
customThemeState.colors[key] ?? "#000000";
|
||||
@@ -1314,7 +1314,7 @@ export function SettingsDialog({
|
||||
</div>
|
||||
|
||||
{subPage ? (
|
||||
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border">
|
||||
<div className="shrink-0 flex items-center justify-end gap-2 pt-2 border-t border-border w-full max-w-2xl mx-auto">
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
|
||||
@@ -410,7 +410,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{/* Navigator Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.navigatorProperties")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="user-agent">{t("fingerprint.userAgent")}</Label>
|
||||
<Input
|
||||
@@ -566,7 +566,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{/* Screen Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.screenProperties")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screen-width">
|
||||
{t("fingerprint.screenWidth")}
|
||||
@@ -687,7 +687,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{/* Window Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.windowProperties")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="outer-width">
|
||||
{t("fingerprint.outerWidth")}
|
||||
@@ -800,7 +800,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{/* Geolocation */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.geolocation")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="latitude">{t("fingerprint.latitude")}</Label>
|
||||
<Input
|
||||
@@ -860,7 +860,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{/* Locale */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.locale")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="locale-language">
|
||||
{t("fingerprint.language")}
|
||||
@@ -917,7 +917,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{/* WebGL Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.webglProperties")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webgl-vendor">
|
||||
{t("fingerprint.webglVendor")}
|
||||
@@ -1065,7 +1065,7 @@ export function SharedCamoufoxConfigForm({
|
||||
{/* Battery */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.battery")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
@@ -1158,7 +1158,7 @@ export function SharedCamoufoxConfigForm({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className={`@container space-y-6 ${className}`}>
|
||||
{forceAdvanced ? (
|
||||
// Advanced mode only (for editing)
|
||||
renderAdvancedForm()
|
||||
@@ -1265,7 +1265,7 @@ export function SharedCamoufoxConfigForm({
|
||||
className="space-y-3"
|
||||
>
|
||||
<Label>{t("fingerprint.screenResolution")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screen-max-width">
|
||||
{t("fingerprint.maxWidth")}
|
||||
|
||||
@@ -21,7 +21,7 @@ interface ShortcutsPageProps {
|
||||
|
||||
function Tokens({ tokens }: { tokens: string[] }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{tokens.map((tok, i) => (
|
||||
<kbd
|
||||
key={i}
|
||||
@@ -72,7 +72,12 @@ export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
|
||||
key={s.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm">{t(s.labelKey)}</span>
|
||||
<span
|
||||
className="text-sm truncate min-w-0"
|
||||
title={t(s.labelKey)}
|
||||
>
|
||||
{t(s.labelKey)}
|
||||
</span>
|
||||
<ShortcutTokens shortcut={s} />
|
||||
</div>
|
||||
))}
|
||||
@@ -92,7 +97,12 @@ export function ShortcutsPage({ groupTargets }: ShortcutsPageProps) {
|
||||
key={target.id}
|
||||
className="flex items-center justify-between gap-4 px-3 py-2"
|
||||
>
|
||||
<span className="text-sm">{target.name}</span>
|
||||
<span
|
||||
className="text-sm truncate min-w-0"
|
||||
title={target.name}
|
||||
>
|
||||
{target.name}
|
||||
</span>
|
||||
<Tokens tokens={formatGroupShortcut(i + 1)} />
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function SyncFollowerDialog({
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[150px]">
|
||||
<ScrollArea className="h-[clamp(120px,30vh,20rem)]">
|
||||
<div className="space-y-1 p-2">
|
||||
{eligibleProfiles.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
|
||||
@@ -127,7 +127,7 @@ const TruncatedDomain = React.memo<{ domain: string }>(({ domain }) => {
|
||||
}, [checkTruncation]);
|
||||
|
||||
const content = (
|
||||
<span ref={ref} className="truncate max-w-[200px] block">
|
||||
<span ref={ref} className="truncate block min-w-0 flex-1">
|
||||
{domain}
|
||||
</span>
|
||||
);
|
||||
@@ -257,7 +257,7 @@ export function TrafficDetailsDialog({
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-[min(56rem,calc(100%-4rem))]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("traffic.title")}
|
||||
@@ -303,7 +303,7 @@ export function TrafficDetailsDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="h-[200px] w-full">
|
||||
<div className="h-[clamp(200px,28vh,360px)] w-full">
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height="100%"
|
||||
@@ -509,7 +509,7 @@ export function TrafficDetailsDialog({
|
||||
{t("traffic.columnReceived")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-auto">
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByTraffic.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
@@ -558,7 +558,7 @@ export function TrafficDetailsDialog({
|
||||
{t("traffic.columnTotal")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-[180px] overflow-y-auto">
|
||||
<div className="max-h-[clamp(180px,25vh,400px)] overflow-y-auto">
|
||||
{topDomainsByRequests.map((domain, index) => (
|
||||
<div
|
||||
key={domain.domain}
|
||||
@@ -591,7 +591,7 @@ export function TrafficDetailsDialog({
|
||||
<h3 className="text-sm font-medium mb-2">
|
||||
{t("traffic.uniqueIps", { count: stats.unique_ips.length })}
|
||||
</h3>
|
||||
<FadingScrollArea className="p-3 max-h-[120px]">
|
||||
<FadingScrollArea className="p-3 max-h-[clamp(120px,15vh,240px)]">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{stats.unique_ips.map((ip) => (
|
||||
<span
|
||||
|
||||
@@ -78,7 +78,7 @@ function AnimatedTabsList({
|
||||
<TabsPrimitive.List
|
||||
data-slot="animated-tabs-list"
|
||||
className={cn(
|
||||
"relative inline-flex items-center gap-1 rounded-md p-0",
|
||||
"relative inline-flex max-w-full items-center gap-1 overflow-x-auto rounded-md p-0 [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
onMouseLeave={(event) => {
|
||||
|
||||
@@ -42,12 +42,14 @@ function AutoHeight({
|
||||
|
||||
return (
|
||||
<Comp
|
||||
style={{ overflow: "hidden", ...style }}
|
||||
style={{ overflow: "hidden", maxHeight: "100%", ...style }}
|
||||
animate={{ height, ...animate }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
<div ref={ref} className="min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ const ChartContainer = React.forwardRef<
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
"flex aspect-video max-h-[min(45vh,20rem)] w-full justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -64,13 +64,18 @@ export function Combobox({
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between", className)}
|
||||
>
|
||||
{value
|
||||
? options.find((option) => option.value === value)?.label
|
||||
: resolvedPlaceholder}
|
||||
<span className="truncate">
|
||||
{value
|
||||
? options.find((option) => option.value === value)?.label
|
||||
: resolvedPlaceholder}
|
||||
</span>
|
||||
<LuChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent id={listboxId} className="w-full p-0">
|
||||
<PopoverContent
|
||||
id={listboxId}
|
||||
className="w-(--radix-popover-trigger-width) p-0"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={resolvedSearchPlaceholder} />
|
||||
<CommandList>
|
||||
@@ -91,10 +96,10 @@ export function Combobox({
|
||||
value === option.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate">{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ function CommandDialog({
|
||||
<DialogTitle>{resolvedTitle}</DialogTitle>
|
||||
<DialogDescription>{resolvedDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<DialogContent className="overflow-hidden p-0 sm:max-w-xl">
|
||||
<Command
|
||||
filter={filter}
|
||||
shouldFilter={shouldFilter}
|
||||
@@ -96,7 +96,7 @@ function CommandList({
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
"max-h-[min(50vh,500px)] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -179,6 +179,7 @@ function SubPageContent({
|
||||
gap: 12,
|
||||
overflow: "auto",
|
||||
background: "var(--background)",
|
||||
containerType: "inline-size",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -254,7 +255,10 @@ function DialogContent({
|
||||
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
|
||||
}
|
||||
className={cn(
|
||||
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg",
|
||||
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
|
||||
// gutter even when callers override max-w-*: tailwind-merge drops
|
||||
// a base max-w in favor of the caller's, but leaves width alone.
|
||||
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg max-h-[calc(100vh-3rem)] overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -282,7 +286,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
className={cn("flex flex-col gap-2 text-left pr-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -293,7 +297,7 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
"flex flex-row flex-wrap justify-end gap-2 shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -224,13 +224,15 @@ function DropdownMenuSubTrigger({
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
collisionPadding = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -21,6 +21,7 @@ function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
collisionPadding = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
@@ -29,8 +30,9 @@ function PopoverContent({
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={collisionPadding}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -4,9 +4,16 @@ import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
function Table({
|
||||
className,
|
||||
containerClassName,
|
||||
...props
|
||||
}: React.ComponentProps<"table"> & { containerClassName?: string }) {
|
||||
return (
|
||||
<div data-slot="table-container" className="overflow-visible w-full">
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className={cn("relative w-full overflow-x-auto", containerClassName)}
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full text-sm caption-bottom", className)}
|
||||
|
||||
@@ -78,7 +78,7 @@ const TabsList = React.forwardRef<
|
||||
ref={ref}
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-10 max-w-full items-center justify-center overflow-x-auto rounded-md bg-muted p-1 text-muted-foreground [scrollbar-width:none]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -168,6 +168,10 @@ function isAutoMode(props: TabsContentsProps): props is TabsContentsAutoProps {
|
||||
return !("mode" in props) || props.mode === "auto-height";
|
||||
}
|
||||
|
||||
// Auto-height mode animates to a measured pixel height; in a
|
||||
// height-constrained parent (e.g. a dialog capped at the viewport) the pane
|
||||
// itself must carry "overflow-y-auto min-h-0" so overflow scrolls instead of
|
||||
// clipping.
|
||||
function TabsContents(props: TabsContentsProps) {
|
||||
const { value } = useTabs();
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ function TooltipContent({
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit max-w-[min(24rem,calc(100vw-2rem))] origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -194,7 +194,7 @@ export function VpnFormDialog({
|
||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
<ScrollArea className="max-h-[min(60vh,calc(100vh-15rem))] overflow-y-auto pr-4">
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-name">{t("vpns.form.name")}</Label>
|
||||
|
||||
@@ -275,7 +275,7 @@ export function VpnImportDialog({ isOpen, onClose }: VpnImportDialogProps) {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("vpns.import.configPreview")}</Label>
|
||||
<ScrollArea className="h-[150px] border rounded-md">
|
||||
<ScrollArea className="h-[min(150px,25vh)] border rounded-md">
|
||||
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{vpnPreview.content.slice(0, 1000)}
|
||||
{vpnPreview.content.length > 1000 && "..."}
|
||||
|
||||
@@ -290,8 +290,8 @@ export function WayfernConfigForm({
|
||||
{/* User Agent and Platform */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.userAgentAndPlatform")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2 col-span-full">
|
||||
<Label htmlFor="user-agent">{t("fingerprint.userAgent")}</Label>
|
||||
<Input
|
||||
id="user-agent"
|
||||
@@ -381,7 +381,7 @@ export function WayfernConfigForm({
|
||||
{/* Hardware Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.hardwareProperties")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="hardware-concurrency">
|
||||
{t("fingerprint.hardwareConcurrency")}
|
||||
@@ -439,7 +439,7 @@ export function WayfernConfigForm({
|
||||
{/* Screen Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.screenProperties")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screen-width">
|
||||
{t("fingerprint.screenWidth")}
|
||||
@@ -561,7 +561,7 @@ export function WayfernConfigForm({
|
||||
{/* Window Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.windowProperties")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="window-outer-width">
|
||||
{t("fingerprint.outerWidth")}
|
||||
@@ -674,7 +674,7 @@ export function WayfernConfigForm({
|
||||
{/* Language & Locale */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.languageAndLocale")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="language">
|
||||
{t("fingerprint.primaryLanguage")}
|
||||
@@ -756,7 +756,7 @@ export function WayfernConfigForm({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fingerprint.timezoneGeolocationDescription")}
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">
|
||||
{t("fingerprint.timezoneIana")}
|
||||
@@ -853,7 +853,7 @@ export function WayfernConfigForm({
|
||||
{/* WebGL Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.webglProperties")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webgl-vendor">
|
||||
{t("fingerprint.webglVendor")}
|
||||
@@ -951,7 +951,7 @@ export function WayfernConfigForm({
|
||||
{/* Audio */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.audioProperties")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="audio-sample-rate">
|
||||
{t("fingerprint.sampleRate")}
|
||||
@@ -994,7 +994,7 @@ export function WayfernConfigForm({
|
||||
{/* Battery */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.battery")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
@@ -1040,7 +1040,7 @@ export function WayfernConfigForm({
|
||||
{/* Vendor Info */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.vendorInfo")}</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 @2xl:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendor">{t("fingerprint.vendor")}</Label>
|
||||
<Input
|
||||
@@ -1114,7 +1114,7 @@ export function WayfernConfigForm({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
<div className={`@container space-y-6 ${className}`}>
|
||||
{forceAdvanced ? (
|
||||
renderAdvancedForm()
|
||||
) : (
|
||||
@@ -1228,7 +1228,7 @@ export function WayfernConfigForm({
|
||||
className="space-y-3"
|
||||
>
|
||||
<Label>{t("fingerprint.screenResolution")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screen-max-width">
|
||||
{t("fingerprint.maxWidth")}
|
||||
|
||||
@@ -120,7 +120,7 @@ export function WelcomeDialog({
|
||||
<Dialog open={isOpen} onOpenChange={() => {}}>
|
||||
<DialogContent
|
||||
dismissible={false}
|
||||
className="overflow-hidden sm:max-w-xl"
|
||||
className="overflow-x-hidden sm:max-w-xl"
|
||||
>
|
||||
<DialogTitle className="sr-only">{t("welcome.title")}</DialogTitle>
|
||||
|
||||
|
||||
@@ -16,11 +16,36 @@ function detectPlatform(): Platform {
|
||||
export function WindowDragArea() {
|
||||
const { t } = useTranslation();
|
||||
const [platform, setPlatform] = useState<Platform | null>(null);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setPlatform(detectPlatform());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (platform !== "windows") return;
|
||||
const win = getCurrentWindow();
|
||||
let cancelled = false;
|
||||
const sync = async () => {
|
||||
try {
|
||||
const maximized = await win.isMaximized();
|
||||
if (!cancelled) setIsMaximized(maximized);
|
||||
} catch (error) {
|
||||
console.error("Failed to read window maximized state:", error);
|
||||
}
|
||||
};
|
||||
void sync();
|
||||
const unlistenPromise = win.onResized(() => {
|
||||
void sync();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
void unlistenPromise.then((unlisten) => {
|
||||
unlisten();
|
||||
});
|
||||
};
|
||||
}, [platform]);
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
@@ -53,8 +78,8 @@ export function WindowDragArea() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Windows: minimize/close controls anchored at the top-right corner of
|
||||
// the sys-bar. The HomeHeader's own drag-region overlay handles window
|
||||
// Windows: minimize/maximize/close controls anchored at the top-right
|
||||
// corner of the sys-bar. The HomeHeader's own drag-region overlay handles window
|
||||
// dragging via Tauri 2, so we don't need a separate draggable spacer
|
||||
// covering the whole width.
|
||||
const handleMinimize = async () => {
|
||||
@@ -65,6 +90,14 @@ export function WindowDragArea() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleMaximize = async () => {
|
||||
try {
|
||||
await getCurrentWindow().toggleMaximize();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle window maximize:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
try {
|
||||
await getCurrentWindow().close();
|
||||
@@ -98,6 +131,45 @@ export function WindowDragArea() {
|
||||
<rect width="10" height="1" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void handleToggleMaximize();
|
||||
}}
|
||||
className="flex items-center justify-center w-11 h-full hover:bg-muted/50 transition-colors text-muted-foreground hover:text-foreground"
|
||||
aria-label={
|
||||
isMaximized ? t("common.window.restore") : t("common.window.maximize")
|
||||
}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.restore")}
|
||||
>
|
||||
<rect x="1" y="3" width="6" height="6" />
|
||||
<path d="M3 3 V1 H9 V7 H7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
role="img"
|
||||
aria-label={t("common.window.maximize")}
|
||||
>
|
||||
<rect x="1" y="1" width="8" height="8" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
"unknown": "Unknown error occurred"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "Minimize"
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Command Palette",
|
||||
@@ -399,6 +401,7 @@
|
||||
"newProxy": "New proxy",
|
||||
"newVpn": "New VPN",
|
||||
"protocolCol": "Protocol",
|
||||
"hostPort": "Host : port",
|
||||
"title": "Proxies & VPNs"
|
||||
},
|
||||
"add": "Add Proxy",
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
"unknown": "Ocurrió un error desconocido"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "Minimizar"
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Maximizar",
|
||||
"restore": "Restaurar"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Paleta de comandos",
|
||||
@@ -399,6 +401,7 @@
|
||||
"newProxy": "Nuevo proxy",
|
||||
"newVpn": "Nueva VPN",
|
||||
"protocolCol": "Protocolo",
|
||||
"hostPort": "Host : puerto",
|
||||
"title": "Proxies y VPN"
|
||||
},
|
||||
"add": "Agregar Proxy",
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
"unknown": "Une erreur inconnue est survenue"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "Réduire"
|
||||
"minimize": "Réduire",
|
||||
"maximize": "Agrandir",
|
||||
"restore": "Restaurer"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Palette de commandes",
|
||||
@@ -399,6 +401,7 @@
|
||||
"newProxy": "Nouveau proxy",
|
||||
"newVpn": "Nouveau VPN",
|
||||
"protocolCol": "Protocole",
|
||||
"hostPort": "Hôte : port",
|
||||
"title": "Proxys et VPN"
|
||||
},
|
||||
"add": "Ajouter un proxy",
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
"unknown": "不明なエラーが発生しました"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "最小化"
|
||||
"minimize": "最小化",
|
||||
"maximize": "最大化",
|
||||
"restore": "元に戻す"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "コマンドパレット",
|
||||
@@ -399,6 +401,7 @@
|
||||
"newProxy": "新しいプロキシ",
|
||||
"newVpn": "新しいVPN",
|
||||
"protocolCol": "プロトコル",
|
||||
"hostPort": "ホスト : ポート",
|
||||
"title": "プロキシと VPN"
|
||||
},
|
||||
"add": "プロキシを追加",
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
"unknown": "알 수 없는 오류가 발생했습니다"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "최소화"
|
||||
"minimize": "최소화",
|
||||
"maximize": "최대화",
|
||||
"restore": "이전 크기로 복원"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "명령 팔레트",
|
||||
@@ -399,6 +401,7 @@
|
||||
"newProxy": "새 프록시",
|
||||
"newVpn": "새 VPN",
|
||||
"protocolCol": "프로토콜",
|
||||
"hostPort": "호스트 : 포트",
|
||||
"title": "프록시 및 VPN"
|
||||
},
|
||||
"add": "프록시 추가",
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
"unknown": "Ocorreu um erro desconhecido"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "Minimizar"
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Maximizar",
|
||||
"restore": "Restaurar"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Paleta de comandos",
|
||||
@@ -399,6 +401,7 @@
|
||||
"newProxy": "Novo proxy",
|
||||
"newVpn": "Nova VPN",
|
||||
"protocolCol": "Protocolo",
|
||||
"hostPort": "Host : porta",
|
||||
"title": "Proxies e VPNs"
|
||||
},
|
||||
"add": "Adicionar Proxy",
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
"unknown": "Произошла неизвестная ошибка"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "Свернуть"
|
||||
"minimize": "Свернуть",
|
||||
"maximize": "Развернуть",
|
||||
"restore": "Восстановить"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Палитра команд",
|
||||
@@ -399,6 +401,7 @@
|
||||
"newProxy": "Новый прокси",
|
||||
"newVpn": "Новый VPN",
|
||||
"protocolCol": "Протокол",
|
||||
"hostPort": "Хост : порт",
|
||||
"title": "Прокси и VPN"
|
||||
},
|
||||
"add": "Добавить прокси",
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
"unknown": "Đã xảy ra lỗi không xác định"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "Thu nhỏ"
|
||||
"minimize": "Thu nhỏ",
|
||||
"maximize": "Phóng to",
|
||||
"restore": "Khôi phục"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Bảng lệnh",
|
||||
@@ -399,6 +401,7 @@
|
||||
"newProxy": "Proxy mới",
|
||||
"newVpn": "VPN mới",
|
||||
"protocolCol": "Giao thức",
|
||||
"hostPort": "Máy chủ : cổng",
|
||||
"title": "Proxy & VPN"
|
||||
},
|
||||
"add": "Thêm Proxy",
|
||||
|
||||
@@ -90,7 +90,9 @@
|
||||
"unknown": "发生未知错误"
|
||||
},
|
||||
"window": {
|
||||
"minimize": "最小化"
|
||||
"minimize": "最小化",
|
||||
"maximize": "最大化",
|
||||
"restore": "还原"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "命令面板",
|
||||
@@ -399,6 +401,7 @@
|
||||
"newProxy": "新建代理",
|
||||
"newVpn": "新建 VPN",
|
||||
"protocolCol": "协议",
|
||||
"hostPort": "主机 : 端口",
|
||||
"title": "代理和 VPN"
|
||||
},
|
||||
"add": "添加代理",
|
||||
|
||||
Reference in New Issue
Block a user