From 9653e16a68253d42a1cee8221a623e93f9373af6 Mon Sep 17 00:00:00 2001 From: mofeng-git Date: Sun, 12 Apr 2026 19:26:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CLI=20=E6=94=B9=E5=AF=86=E3=80=81?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=20TLS=E3=80=81=E7=A7=BB=E5=8A=A8?= =?UTF-8?q?=E7=AB=AF=E9=80=82=E9=85=8D=E4=B8=8E=E6=89=A9=E5=B1=95=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 one-kvm user set-password(交互式),改密后吊销该用户全部会话 - /api/config/web 支持 PEM 证书/密钥上传与清除,响应含 has_custom_cert - 移动端:ActionBar 溢出菜单、ATX/粘贴底部 Sheet、BrandMark 与控制台等响应式优化 - GOSTC:校验服务器地址非空,管理器启动条件与 HTTP 热更新一致 - RustDesk:中继密钥 relay_key 校验为标准 Base64 且解码后恰好 32 字节 - StatusCard、InfoBar:合并精简冗余状态信息 --- src/auth/session.rs | 9 + src/config/schema.rs | 4 +- src/extensions/manager.rs | 16 +- src/extensions/types.rs | 4 +- src/main.rs | 108 +++- src/web/handlers/config/types.rs | 123 ++++- src/web/handlers/config/web.rs | 89 +++- src/web/handlers/extensions.rs | 2 + web/src/api/config.ts | 28 +- web/src/api/index.ts | 13 +- web/src/components/ActionBar.vue | 340 ++++++++----- web/src/components/AppLayout.vue | 48 +- web/src/components/AudioConfigPopover.vue | 6 +- web/src/components/BrandMark.vue | 39 ++ web/src/components/HidConfigPopover.vue | 8 +- web/src/components/InfoBar.vue | 127 +++-- web/src/components/StatusCard.vue | 94 ++-- web/src/components/VideoConfigPopover.vue | 6 +- web/src/components/VirtualKeyboard.vue | 92 +++- web/src/i18n/en-US.ts | 63 ++- web/src/i18n/zh-CN.ts | 59 ++- web/src/stores/config.ts | 4 +- web/src/types/generated.ts | 29 +- web/src/views/ConsoleView.vue | 230 ++++----- web/src/views/LoginView.vue | 37 +- web/src/views/SettingsView.vue | 570 +++++++++++++++++----- web/src/views/SetupView.vue | 8 +- 27 files changed, 1527 insertions(+), 629 deletions(-) create mode 100644 web/src/components/BrandMark.vue diff --git a/src/auth/session.rs b/src/auth/session.rs index 6106dc16..5ad92baa 100644 --- a/src/auth/session.rs +++ b/src/auth/session.rs @@ -126,6 +126,15 @@ impl SessionStore { Ok(result.rows_affected()) } + /// Delete all sessions for a specific user + pub async fn delete_by_user_id(&self, user_id: &str) -> Result { + let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?1") + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(result.rows_affected()) + } + /// List all session IDs pub async fn list_ids(&self) -> Result> { let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions") diff --git a/src/config/schema.rs b/src/config/schema.rs index 094a479b..59f6dd29 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -703,7 +703,9 @@ impl StreamConfig { } } -/// Web server configuration +/// Web server configuration persisted in the database (includes on-disk TLS paths). +/// +/// The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`. #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default)] diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index baac3561..fd03d46a 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -280,6 +280,9 @@ impl ExtensionManager { ExtensionId::Gostc => { let c = &config.gostc; + if c.addr.trim().is_empty() { + return Err("GOSTC server address is required".into()); + } if c.key.is_empty() { return Err("GOSTC client key is required".into()); } @@ -291,10 +294,8 @@ impl ExtensionManager { args.push("--tls=true".to_string()); } - // Add server address - if !c.addr.is_empty() { - args.extend(["-addr".to_string(), c.addr.clone()]); - } + // Server address (validated non-empty above) + args.extend(["-addr".to_string(), c.addr.trim().to_string()]); // Add client key args.extend(["-key".to_string(), c.key.clone()]); @@ -375,7 +376,11 @@ impl ExtensionManager { .filter_map(|id| { let should_run = match id { ExtensionId::Ttyd => config.ttyd.enabled, - ExtensionId::Gostc => config.gostc.enabled && !config.gostc.key.is_empty(), + ExtensionId::Gostc => { + config.gostc.enabled + && !config.gostc.key.is_empty() + && !config.gostc.addr.trim().is_empty() + } ExtensionId::Easytier => { config.easytier.enabled && !config.easytier.network_name.is_empty() } @@ -435,6 +440,7 @@ impl ExtensionManager { if config.gostc.enabled && !config.gostc.key.is_empty() + && !config.gostc.addr.trim().is_empty() && self.check_available(ExtensionId::Gostc) { start_futures.push(Box::pin(async { diff --git a/src/extensions/types.rs b/src/extensions/types.rs index a5dc795c..2ecda744 100644 --- a/src/extensions/types.rs +++ b/src/extensions/types.rs @@ -121,7 +121,7 @@ impl Default for TtydConfig { pub struct GostcConfig { /// Enable auto-start pub enabled: bool, - /// Server address (e.g., gostc.mofeng.run) + /// Server address (hostname or IP) pub addr: String, /// Client key from GOSTC management panel #[serde(skip_serializing_if = "String::is_empty")] @@ -134,7 +134,7 @@ impl Default for GostcConfig { fn default() -> Self { Self { enabled: false, - addr: "gostc.mofeng.run".to_string(), + addr: String::new(), key: String::new(), tls: true, } diff --git a/src/main.rs b/src/main.rs index 7f518ec9..27cf27d4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ use std::collections::HashSet; +use std::io::Write; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; use std::sync::Arc; use axum_server::tls_rustls::RustlsConfig; -use clap::{Parser, ValueEnum}; +use clap::{Args, Parser, Subcommand, ValueEnum}; use futures::{stream::FuturesUnordered, StreamExt}; use rustls::crypto::{ring, CryptoProvider}; use tokio::sync::{broadcast, mpsc}; @@ -49,6 +50,10 @@ enum LogLevel { #[command(name = "one-kvm")] #[command(version, about = "A open and lightweight IP-KVM solution", long_about = None)] struct CliArgs { + /// User management commands + #[command(subcommand)] + command: Option, + /// Listen address (overrides database config) #[arg(short = 'a', long, value_name = "ADDRESS")] address: Option, @@ -86,6 +91,24 @@ struct CliArgs { verbose: u8, } +#[derive(Subcommand, Debug)] +enum CliCommand { + /// Manage local users + User(UserCommand), +} + +#[derive(Args, Debug)] +struct UserCommand { + #[command(subcommand)] + action: UserAction, +} + +#[derive(Subcommand, Debug)] +enum UserAction { + /// Set password for the single local user (interactive terminal prompt) + SetPassword, +} + #[tokio::main] async fn main() -> anyhow::Result<()> { // Parse command line arguments @@ -101,9 +124,15 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION")); // Determine data directory (CLI arg takes precedence) - let data_dir = args.data_dir.unwrap_or_else(get_data_dir); + let data_dir = args.data_dir.clone().unwrap_or_else(get_data_dir); tracing::info!("Data directory: {}", data_dir.display()); + // Run one-off CLI command and exit. + if let Some(command) = args.command { + run_cli_command(command, data_dir).await?; + return Ok(()); + } + // Ensure data directory exists tokio::fs::create_dir_all(&data_dir).await?; @@ -765,6 +794,81 @@ fn get_data_dir() -> PathBuf { PathBuf::from("/etc/one-kvm") } +async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> { + tokio::fs::create_dir_all(&data_dir).await?; + let db_path = data_dir.join("one-kvm.db"); + let config_store = ConfigStore::new(&db_path).await?; + let users = UserStore::new(config_store.pool().clone()); + let sessions = SessionStore::new(config_store.pool().clone(), 0); + + match command { + CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await, + } +} + +async fn run_user_action( + action: UserAction, + users: &UserStore, + sessions: &SessionStore, +) -> anyhow::Result<()> { + match action { + UserAction::SetPassword => set_user_password(users, sessions).await, + } +} + +async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> { + let all = users.list().await?; + let user = match all.len() { + 0 => anyhow::bail!("No local user exists yet; complete setup in the web UI first."), + 1 => &all[0], + _ => anyhow::bail!( + "Expected exactly one local user (single-user design), found {}. Remove extra users from the database or contact support.", + all.len() + ), + }; + + let new_password = read_new_password_interactive()?; + if new_password.len() < 4 { + anyhow::bail!("Password must be at least 4 characters"); + } + + users.update_password(&user.id, &new_password).await?; + let revoked = sessions.delete_by_user_id(&user.id).await?; + + tracing::info!( + "Password updated for user '{}' and {} sessions revoked", + user.username, + revoked + ); + println!( + "Password updated for user '{}' (revoked {} sessions).", + user.username, revoked + ); + Ok(()) +} + +fn read_new_password_interactive() -> anyhow::Result { + let once = |label: &str| -> anyhow::Result { + print!("{}", label); + std::io::stdout().flush()?; + + let mut line = String::new(); + std::io::stdin().read_line(&mut line)?; + let s = line.trim_end_matches(['\r', '\n']).to_string(); + if s.is_empty() { + anyhow::bail!("Password cannot be empty"); + } + Ok(s) + }; + + let a = once("New password: ")?; + let b = once("Confirm password: ")?; + if a != b { + anyhow::bail!("Passwords do not match"); + } + Ok(a) +} + /// Resolve bind IPs from config, preferring bind_addresses when set. fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result> { let raw_addrs = if !web.bind_addresses.is_empty() { diff --git a/src/web/handlers/config/types.rs b/src/web/handlers/config/types.rs index d26131a1..fddff512 100644 --- a/src/web/handlers/config/types.rs +++ b/src/web/handlers/config/types.rs @@ -3,7 +3,8 @@ use crate::error::AppError; use crate::rtsp::RtspServiceStatus; use crate::rustdesk::config::RustDeskConfig; use crate::video::encoder::BitratePreset; -use serde::Deserialize; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; use std::path::Path; use typeshare::typeshare; @@ -660,6 +661,26 @@ impl AudioConfigUpdate { } // ===== RustDesk Config ===== + +/// hbbs/hbbr `-k` relay key: standard Base64 encoding of exactly 32 bytes (typically 44 chars with padding). +fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> { + let decoded = STANDARD.decode(key.as_bytes()).map_err(|_| { + AppError::BadRequest( + "Relay key must be standard Base64 (32 raw bytes, e.g. hbbs/hbbr -k output)".into(), + ) + })?; + if decoded.len() != 32 { + return Err(AppError::BadRequest( + format!( + "Relay key must decode to exactly 32 bytes (got {} bytes after Base64 decode)", + decoded.len() + ) + .into(), + )); + } + Ok(()) +} + #[typeshare] #[derive(Debug, Deserialize)] pub struct RustDeskConfigUpdate { @@ -698,6 +719,12 @@ impl RustDeskConfigUpdate { )); } } + if let Some(ref key) = self.relay_key { + let trimmed = key.trim(); + if !trimmed.is_empty() { + validate_rustdesk_relay_key(trimmed)?; + } + } Ok(()) } @@ -716,10 +743,11 @@ impl RustDeskConfigUpdate { }; } if let Some(ref key) = self.relay_key { - config.relay_key = if key.is_empty() { + let trimmed = key.trim(); + config.relay_key = if trimmed.is_empty() { None } else { - Some(key.clone()) + Some(trimmed.to_string()) }; } if let Some(ref password) = self.device_password { @@ -849,6 +877,45 @@ impl RtspConfigUpdate { } // ===== Web Config ===== + +/// Web server settings returned by `GET` / `PATCH /api/config/web`. +/// +/// Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WebConfigResponse { + pub http_port: u16, + pub https_port: u16, + pub bind_addresses: Vec, + pub bind_address: String, + pub https_enabled: bool, + /// Whether a custom TLS certificate is active (non-empty cert + key paths in stored config). + pub has_custom_cert: bool, +} + +impl WebConfigResponse { + pub fn from_stored(web: &WebConfig) -> Self { + let has_custom_cert = web + .ssl_cert_path + .as_deref() + .map(|p| !p.is_empty()) + .unwrap_or(false) + && web + .ssl_key_path + .as_deref() + .map(|p| !p.is_empty()) + .unwrap_or(false); + Self { + http_port: web.http_port, + https_port: web.https_port, + bind_addresses: web.bind_addresses.clone(), + bind_address: web.bind_address.clone(), + https_enabled: web.https_enabled, + has_custom_cert, + } + } +} + #[typeshare] #[derive(Debug, Deserialize)] pub struct WebConfigUpdate { @@ -857,6 +924,12 @@ pub struct WebConfigUpdate { pub bind_addresses: Option>, pub bind_address: Option, pub https_enabled: Option, + /// PEM-encoded certificate content (must be provided together with ssl_key_pem) + pub ssl_cert_pem: Option, + /// PEM-encoded private key content (must be provided together with ssl_cert_pem) + pub ssl_key_pem: Option, + /// Set to true to remove the custom certificate and revert to self-signed + pub clear_custom_cert: Option, } impl WebConfigUpdate { @@ -883,6 +956,22 @@ impl WebConfigUpdate { return Err(AppError::BadRequest("Invalid bind address".into())); } } + // Cert and key must be provided together (cryptographic validity is checked in the + // handler via `RustlsConfig::from_pem`, same stack as the running HTTPS server). + match (&self.ssl_cert_pem, &self.ssl_key_pem) { + (Some(_cert), Some(_key)) => {} + (Some(_), None) => { + return Err(AppError::BadRequest( + "ssl_key_pem is required when ssl_cert_pem is provided".into(), + )); + } + (None, Some(_)) => { + return Err(AppError::BadRequest( + "ssl_cert_pem is required when ssl_key_pem is provided".into(), + )); + } + (None, None) => {} + } Ok(()) } @@ -907,6 +996,8 @@ impl WebConfigUpdate { if let Some(enabled) = self.https_enabled { config.https_enabled = enabled; } + // ssl_cert_pem, ssl_key_pem, clear_custom_cert are handled at the handler level + // (they require async file I/O before updating config paths) } } @@ -988,4 +1079,30 @@ mod tests { assert!(update.validate_with_current(¤t).is_err()); } + + #[test] + fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() { + let update = RustDeskConfigUpdate { + enabled: None, + rendezvous_server: None, + relay_server: None, + relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()), + device_password: None, + }; + assert!(update.validate().is_ok()); + } + + #[test] + fn rustdesk_relay_key_rejects_non_32_byte_payload() { + // Standard Base64 for 16 zero bytes (not 32). + let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string(); + let update = RustDeskConfigUpdate { + enabled: None, + rendezvous_server: None, + relay_server: None, + relay_key: Some(not_32), + device_password: None, + }; + assert!(update.validate().is_err()); + } } diff --git a/src/web/handlers/config/web.rs b/src/web/handlers/config/web.rs index fd11c54e..9b3d9b7a 100644 --- a/src/web/handlers/config/web.rs +++ b/src/web/handlers/config/web.rs @@ -1,32 +1,103 @@ //! Web 服务器配置 Handler use axum::{extract::State, Json}; +use axum_server::tls_rustls::RustlsConfig; use std::sync::Arc; -use crate::config::WebConfig; -use crate::error::Result; +use crate::error::{AppError, Result}; use crate::state::AppState; -use super::types::WebConfigUpdate; +use super::types::{WebConfigResponse, WebConfigUpdate}; /// 获取 Web 配置 -pub async fn get_web_config(State(state): State>) -> Json { - Json(state.config.get().web.clone()) +pub async fn get_web_config( + State(state): State>, +) -> Json { + Json(WebConfigResponse::from_stored(&state.config.get().web)) } -/// 更新 Web 配置 +/// 更新 Web 配置(支持 PEM 证书上传) pub async fn update_web_config( State(state): State>, Json(req): Json, -) -> Result> { +) -> Result> { req.validate()?; + // Determine certificate path changes (requires async file I/O before config update) + // Some(Some((cert, key))) = write new cert + // Some(None) = clear custom cert + // None = no cert change + let cert_path_update: Option> = + if let (Some(cert_pem), Some(key_pem)) = (&req.ssl_cert_pem, &req.ssl_key_pem) { + RustlsConfig::from_pem(cert_pem.as_bytes().to_vec(), key_pem.as_bytes().to_vec()) + .await + .map_err(|e| { + AppError::BadRequest( + format!( + "Invalid TLS certificate or private key (PEM must match what the HTTPS server can load): {e}" + ) + .into(), + ) + })?; + let cert_dir = state.data_dir().join("certs"); + tokio::fs::create_dir_all(&cert_dir) + .await + .map_err(|e| AppError::Internal(format!("Failed to create cert dir: {e}")))?; + let cert_path = cert_dir.join("custom.crt"); + let key_path = cert_dir.join("custom.key"); + tokio::fs::write(&cert_path, cert_pem.as_bytes()) + .await + .map_err(|e| AppError::Internal(format!("Failed to write certificate: {e}")))?; + tokio::fs::write(&key_path, key_pem.as_bytes()) + .await + .map_err(|e| AppError::Internal(format!("Failed to write private key: {e}")))?; + Some(Some(( + cert_path.to_string_lossy().into_owned(), + key_path.to_string_lossy().into_owned(), + ))) + } else if req.clear_custom_cert.unwrap_or(false) { + let cert_dir = state.data_dir().join("certs"); + let _ = tokio::fs::remove_file(cert_dir.join("custom.crt")).await; + let _ = tokio::fs::remove_file(cert_dir.join("custom.key")).await; + Some(None) + } else { + None + }; + state .config - .update(|config| { + .update(move |config| { req.apply_to(&mut config.web); + match cert_path_update { + Some(Some((cert_path, key_path))) => { + config.web.ssl_cert_path = Some(cert_path); + config.web.ssl_key_path = Some(key_path); + } + Some(None) => { + config.web.ssl_cert_path = None; + config.web.ssl_key_path = None; + } + None => {} + } }) .await?; - Ok(Json(state.config.get().web.clone())) + Ok(Json(WebConfigResponse::from_stored(&state.config.get().web))) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustls::crypto::{ring, CryptoProvider}; + + #[tokio::test] + async fn rustls_accepts_rcgen_self_signed_pem() { + let _ = CryptoProvider::install_default(ring::default_provider()); + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap(); + let cert_pem = cert.cert.pem(); + let key_pem = cert.signing_key.serialize_pem(); + RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes()) + .await + .unwrap(); + } } diff --git a/src/web/handlers/extensions.rs b/src/web/handlers/extensions.rs index 30e22d16..29fd1c87 100644 --- a/src/web/handlers/extensions.rs +++ b/src/web/handlers/extensions.rs @@ -256,12 +256,14 @@ pub async fn update_gostc_config( let new_config = state.config.get(); let is_enabled = new_config.extensions.gostc.enabled; let has_key = !new_config.extensions.gostc.key.is_empty(); + let has_addr = !new_config.extensions.gostc.addr.trim().is_empty(); if was_enabled && !is_enabled { state.extensions.stop(ExtensionId::Gostc).await.ok(); } else if !was_enabled && is_enabled && has_key + && has_addr && state.extensions.check_available(ExtensionId::Gostc) { state diff --git a/web/src/api/config.ts b/web/src/api/config.ts index 1fe4e919..e7ecbfc4 100644 --- a/web/src/api/config.ts +++ b/web/src/api/config.ts @@ -30,6 +30,8 @@ import type { GostcConfigUpdate, EasytierConfig, EasytierConfigUpdate, + WebConfigResponse, + WebConfigUpdate, } from '@/types/generated' import { request } from './request' @@ -384,36 +386,24 @@ export const rtspConfigApi = { } // ===== Web 服务器配置 API ===== +// `/config/web` 使用 `WebConfigResponse` / `WebConfigUpdate`(由 typeshare 自 Rust 生成)。 -/** Web 服务器配置 */ -export interface WebConfig { - http_port: number - https_port: number - bind_addresses: string[] - bind_address: string - https_enabled: boolean -} +/** REST `/config/web` 响应(`WebConfigResponse` 别名,兼容旧命名) */ +export type WebConfig = WebConfigResponse -/** Web 服务器配置更新 */ -export interface WebConfigUpdate { - http_port?: number - https_port?: number - bind_addresses?: string[] - bind_address?: string - https_enabled?: boolean -} +export type { WebConfigUpdate } export const webConfigApi = { /** * 获取 Web 服务器配置 */ - get: () => request('/config/web'), + get: () => request('/config/web'), /** - * 更新 Web 服务器配置 + * 更新 Web 服务器配置(含可选的证书上传) */ update: (config: WebConfigUpdate) => - request('/config/web', { + request('/config/web', { method: 'PATCH', body: JSON.stringify(config), }), diff --git a/web/src/api/index.ts b/web/src/api/index.ts index 4a27a420..faaa99b8 100644 --- a/web/src/api/index.ts +++ b/web/src/api/index.ts @@ -8,10 +8,14 @@ const API_BASE = '/api' // Auth API export const authApi = { login: (username: string, password: string) => - request<{ success: boolean; message?: string }>('/auth/login', { - method: 'POST', - body: JSON.stringify({ username, password }), - }), + request<{ success: boolean; message?: string }>( + '/auth/login', + { + method: 'POST', + body: JSON.stringify({ username, password }), + }, + { toastOnError: false }, + ), logout: () => request<{ success: boolean }>('/auth/logout', { method: 'POST' }), @@ -688,6 +692,7 @@ export { type RtspConfigUpdate, type RtspStatusResponse, type WebConfig, + type WebConfigUpdate, } from './config' // 导出生成的类型 diff --git a/web/src/components/ActionBar.vue b/web/src/components/ActionBar.vue index 2d2422e5..1db72c57 100644 --- a/web/src/components/ActionBar.vue +++ b/web/src/components/ActionBar.vue @@ -22,6 +22,12 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' import { ClipboardPaste, HardDrive, @@ -74,6 +80,7 @@ const emit = defineEmits<{ (e: 'openTerminal'): void }>() +// Desktop toolbar popover/dialog state const pasteOpen = ref(false) const atxOpen = ref(false) const videoPopoverOpen = ref(false) @@ -81,13 +88,52 @@ const hidPopoverOpen = ref(false) const audioPopoverOpen = ref(false) const msdDialogOpen = ref(false) const extensionOpen = ref(false) + +// Mobile Sheet state — opened from the overflow menu. +// We use Sheet (bottom drawer) instead of Popover because Popover relies on an +// anchor element that is hidden / clipped on small screens, causing it to +// immediately close after opening. +const mobileAtxOpen = ref(false) +const mobilePasteOpen = ref(false) + +// Timestamps used to suppress spurious "interact-outside" events that arrive +// within ~300 ms of the Sheet opening (e.g. delayed synthetic pointer events +// from the same touch gesture that opened the overflow menu). +const mobileAtxOpenTime = ref(0) +const mobilePasteOpenTime = ref(0) + +const OPEN_GUARD_MS = 350 + +const guardOutside = (openTime: number, e: Event) => { + if (Date.now() - openTime < OPEN_GUARD_MS) { + e.preventDefault() + } +} + +// On mobile, clicking a DropdownMenuItem generates pointer events that can +// immediately dismiss any overlay opened in the same tick. Close the dropdown +// first, then open the target after a short delay. +const openFromOverflow = (setter: () => void) => { + overflowMenuOpen.value = false + setTimeout(setter, 50) +} + +const openMobileAtx = () => openFromOverflow(() => { + mobileAtxOpen.value = true + mobileAtxOpenTime.value = Date.now() +}) + +const openMobilePaste = () => openFromOverflow(() => { + mobilePasteOpen.value = true + mobilePasteOpenTime.value = Date.now() +}) diff --git a/web/src/components/AppLayout.vue b/web/src/components/AppLayout.vue index ed85aeb2..61c1c86b 100644 --- a/web/src/components/AppLayout.vue +++ b/web/src/components/AppLayout.vue @@ -1,21 +1,18 @@ + + diff --git a/web/src/components/HidConfigPopover.vue b/web/src/components/HidConfigPopover.vue index f8a9d080..201b8433 100644 --- a/web/src/components/HidConfigPopover.vue +++ b/web/src/components/HidConfigPopover.vue @@ -247,13 +247,13 @@ watch(() => props.open, (isOpen) => {