Compare commits

..

7 Commits

Author SHA1 Message Date
zhom e1fcfd5403 refactor: simplify socks connection 2026-06-17 18:33:09 +04:00
zhom 9dc9e13182 refactor: switch local proxy from http to socks 2026-06-17 18:33:09 +04:00
zhom c5a168ae0f docs: readme 2026-06-17 18:33:09 +04:00
zhom 168b7ac6d4 feat: amek window resizable 2026-06-17 18:33:09 +04:00
dependabot[bot] e5910ad5cf ci(deps): bump anomalyco/opencode in the github-actions group (#437)
Bumps the github-actions group with 1 update: [anomalyco/opencode](https://github.com/anomalyco/opencode).


Updates `anomalyco/opencode` from 1.16.2 to 1.17.4
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/76c631d198f9ff620e15468e45f3457d50481b57...abda3515f444c4d28a98953d153c5a3e1892d3d4)

---
updated-dependencies:
- dependency-name: anomalyco/opencode
  dependency-version: 1.17.4
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-13 09:43:17 +00:00
github-actions[bot] 202f2c852b chore: update flake.nix for v0.26.0 [skip ci] (#428)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-08 02:02:46 +00:00
github-actions[bot] 5a8864654d docs: update CHANGELOG.md and README.md for v0.26.0 [skip ci] (#427)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-08 02:02:30 +00:00
81 changed files with 2275 additions and 907 deletions
+1 -1
View File
@@ -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 }}
+6
View File
@@ -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.
+23
View File
@@ -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
+6 -6
View File
@@ -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:
+5 -5
View File
@@ -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;
+17 -1
View File
@@ -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"
+1
View File
@@ -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"
+7
View File
@@ -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
{
+13 -2
View File
@@ -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
View File
@@ -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
+15 -3
View File
@@ -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();
+4 -2
View File
@@ -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
+68 -26
View File
@@ -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)]
+21
View File
@@ -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 {
+639
View File
@@ -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']
);
}
}
+201 -3
View File
@@ -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);
+14 -2
View File
@@ -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());
+8 -2
View File
@@ -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">
+3 -3
View File
@@ -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>
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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>
+4 -4
View File
@@ -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>
+5 -5
View File
@@ -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>
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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">
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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>
);
+2 -2
View File
@@ -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>
))}
+1 -1
View File
@@ -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(
+94 -43
View File
@@ -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}
+1 -1
View File
@@ -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
-195
View File
@@ -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>
);
}
+28 -13
View File
@@ -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,
+21 -8
View File
@@ -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 ? (
+12 -5
View File
@@ -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" ? (
+12 -6
View File
@@ -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 (
+1 -1
View File
@@ -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">
+31 -4
View File
@@ -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 (
+195 -66
View File
@@ -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>
)}
</>
+1 -1
View File
@@ -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). */}
+1 -1
View File
@@ -193,7 +193,7 @@ export function ProfilePasswordDialog({
if (!open) onClose();
}}
>
<DialogContent>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t(titleKey)}</DialogTitle>
<DialogDescription>
+1 -1
View File
@@ -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>
+112 -110
View File
@@ -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}>
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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")}
+4 -6
View File
@@ -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"
+5 -5
View File
@@ -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">
+371 -245
View File
@@ -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
View File
@@ -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",
+4 -4
View File
@@ -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")}
+13 -3
View File
@@ -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>
))}
+1 -1
View File
@@ -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">
+6 -6
View File
@@ -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
+1 -1
View File
@@ -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) => {
+4 -2
View File
@@ -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>
);
}
+1 -1
View File
@@ -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}
+12 -7
View File
@@ -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>
)}
+2 -2
View File
@@ -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}
+7 -3
View File
@@ -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}
+3 -1
View File
@@ -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}
+3 -1
View File
@@ -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}
+9 -2
View File
@@ -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)}
+5 -1
View File
@@ -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();
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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 && "..."}
+13 -13
View File
@@ -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")}
+1 -1
View File
@@ -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>
+74 -2
View File
@@ -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={() => {
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -90,7 +90,9 @@
"unknown": "不明なエラーが発生しました"
},
"window": {
"minimize": "最小化"
"minimize": "最小化",
"maximize": "最大化",
"restore": "元に戻す"
},
"commandPalette": {
"title": "コマンドパレット",
@@ -399,6 +401,7 @@
"newProxy": "新しいプロキシ",
"newVpn": "新しいVPN",
"protocolCol": "プロトコル",
"hostPort": "ホスト : ポート",
"title": "プロキシと VPN"
},
"add": "プロキシを追加",
+4 -1
View File
@@ -90,7 +90,9 @@
"unknown": "알 수 없는 오류가 발생했습니다"
},
"window": {
"minimize": "최소화"
"minimize": "최소화",
"maximize": "최대화",
"restore": "이전 크기로 복원"
},
"commandPalette": {
"title": "명령 팔레트",
@@ -399,6 +401,7 @@
"newProxy": "새 프록시",
"newVpn": "새 VPN",
"protocolCol": "프로토콜",
"hostPort": "호스트 : 포트",
"title": "프록시 및 VPN"
},
"add": "프록시 추가",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -90,7 +90,9 @@
"unknown": "Произошла неизвестная ошибка"
},
"window": {
"minimize": "Свернуть"
"minimize": "Свернуть",
"maximize": "Развернуть",
"restore": "Восстановить"
},
"commandPalette": {
"title": "Палитра команд",
@@ -399,6 +401,7 @@
"newProxy": "Новый прокси",
"newVpn": "Новый VPN",
"protocolCol": "Протокол",
"hostPort": "Хост : порт",
"title": "Прокси и VPN"
},
"add": "Добавить прокси",
+4 -1
View File
@@ -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",
+4 -1
View File
@@ -90,7 +90,9 @@
"unknown": "发生未知错误"
},
"window": {
"minimize": "最小化"
"minimize": "最小化",
"maximize": "最大化",
"restore": "还原"
},
"commandPalette": {
"title": "命令面板",
@@ -399,6 +401,7 @@
"newProxy": "新建代理",
"newVpn": "新建 VPN",
"protocolCol": "协议",
"hostPort": "主机 : 端口",
"title": "代理和 VPN"
},
"add": "添加代理",