From fd1049412879bcf0c383cbaef34982f1485aa12b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 18 Apr 2026 10:18:34 +0800 Subject: [PATCH] feat(web-service): allow custom access token with persisted port and localized start errors - Persist user-supplied access token and last-used port in app_metadata, falling back to defaults when unset - Atomically guard concurrent starts via compare_exchange with RAII rollback of the running flag - Wrap token and port persistence in a single SeaORM transaction to prevent partial writes - Classify bind errors (port in use, permission denied, address unavailable, invalid address) into stable i18n keys - Localize start-failure messages across all 10 supported languages --- .../src/db/service/app_metadata_service.rs | 6 +- src-tauri/src/lib.rs | 1 + src-tauri/src/web/handlers/web_server.rs | 1 + src-tauri/src/web/mod.rs | 236 +++++++++++++++--- .../settings/web-service-settings.tsx | 119 +++++++-- src/i18n/messages/ar.json | 12 +- src/i18n/messages/de.json | 12 +- src/i18n/messages/en.json | 12 +- src/i18n/messages/es.json | 12 +- src/i18n/messages/fr.json | 12 +- src/i18n/messages/ja.json | 12 +- src/i18n/messages/ko.json | 12 +- src/i18n/messages/pt.json | 12 +- src/i18n/messages/zh-CN.json | 12 +- src/i18n/messages/zh-TW.json | 12 +- src/lib/api.ts | 11 + 16 files changed, 427 insertions(+), 67 deletions(-) diff --git a/src-tauri/src/db/service/app_metadata_service.rs b/src-tauri/src/db/service/app_metadata_service.rs index 8038dea..189fb9b 100644 --- a/src-tauri/src/db/service/app_metadata_service.rs +++ b/src-tauri/src/db/service/app_metadata_service.rs @@ -1,13 +1,13 @@ use chrono::Utc; use sea_orm::sea_query::OnConflict; -use sea_orm::DatabaseConnection; +use sea_orm::{ConnectionTrait, DatabaseConnection}; use sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set}; use crate::db::entities::app_metadata; use crate::db::error::DbError; -pub async fn upsert_value( - conn: &DatabaseConnection, +pub async fn upsert_value( + conn: &C, key: &str, value: &str, ) -> Result<(), DbError> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c4bf8a5..411bf4f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -449,6 +449,7 @@ mod tauri_app { web::start_web_server, web::stop_web_server, web::get_web_server_status, + web::get_web_service_config, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src-tauri/src/web/handlers/web_server.rs b/src-tauri/src/web/handlers/web_server.rs index 31021a0..b5a2f7a 100644 --- a/src-tauri/src/web/handlers/web_server.rs +++ b/src-tauri/src/web/handlers/web_server.rs @@ -18,6 +18,7 @@ pub async fn get_web_server_status( pub struct StartWebServerParams { pub port: Option, pub host: Option, + pub token: Option, } pub async fn start_web_server( diff --git a/src-tauri/src/web/mod.rs b/src-tauri/src/web/mod.rs index dce89b0..de44008 100644 --- a/src-tauri/src/web/mod.rs +++ b/src-tauri/src/web/mod.rs @@ -9,10 +9,16 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU16, Ordering}; use std::sync::{Arc, Mutex}; +use sea_orm::{DatabaseConnection, TransactionError, TransactionTrait}; use serde::Serialize; use crate::app_error::{AppCommandError, AppErrorCode}; use crate::app_state::AppState; +use crate::db::service::app_metadata_service; + +const WEB_SERVICE_TOKEN_KEY: &str = "web_service_token"; +const WEB_SERVICE_PORT_KEY: &str = "web_service_port"; +pub const DEFAULT_WEB_SERVICE_PORT: u16 = 3080; pub struct WebServerState { handle: Mutex>>, @@ -50,6 +56,120 @@ pub fn generate_random_token() -> String { uuid::Uuid::new_v4().to_string().replace('-', "") } +/// Resolve the token to use when starting the Web server: +/// 1. use the explicit override if non-empty; +/// 2. fall back to the persisted value in `AppMetadata`; +/// 3. otherwise generate a fresh random token. +async fn resolve_web_service_token( + conn: &DatabaseConnection, + override_token: Option, +) -> Result { + let trimmed_override = override_token + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + + if let Some(value) = trimmed_override { + return Ok(value); + } + + match app_metadata_service::get_value(conn, WEB_SERVICE_TOKEN_KEY) + .await + .map_err(AppCommandError::from)? + { + Some(saved) if !saved.trim().is_empty() => Ok(saved), + _ => Ok(generate_random_token()), + } +} + +async fn resolve_web_service_port( + conn: &DatabaseConnection, + override_port: Option, +) -> Result { + if let Some(port) = override_port { + return Ok(port); + } + let saved = app_metadata_service::get_value(conn, WEB_SERVICE_PORT_KEY) + .await + .map_err(AppCommandError::from)?; + let port = saved + .as_deref() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(DEFAULT_WEB_SERVICE_PORT); + Ok(port) +} + +/// Persist token and port atomically so a partial failure cannot leave +/// `app_metadata` in a mixed old/new state. +async fn persist_web_service_config( + conn: &DatabaseConnection, + token: &str, + port: u16, +) -> Result<(), AppCommandError> { + // Own the values so the inner future is 'static (required by transaction). + let token_owned = token.to_string(); + let port_str = port.to_string(); + conn.transaction::<_, (), AppCommandError>(move |txn| { + Box::pin(async move { + app_metadata_service::upsert_value(txn, WEB_SERVICE_TOKEN_KEY, &token_owned) + .await + .map_err(AppCommandError::from)?; + app_metadata_service::upsert_value(txn, WEB_SERVICE_PORT_KEY, &port_str) + .await + .map_err(AppCommandError::from)?; + Ok(()) + }) + }) + .await + .map_err(|e: TransactionError| match e { + TransactionError::Connection(db) => AppCommandError::new( + AppErrorCode::DatabaseError, + "Database transaction failed", + ) + .with_detail(db.to_string()), + TransactionError::Transaction(inner) => inner, + }) +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WebServiceConfig { + pub token: Option, + pub port: Option, +} + +pub async fn load_web_service_config( + conn: &DatabaseConnection, +) -> Result { + let token = app_metadata_service::get_value(conn, WEB_SERVICE_TOKEN_KEY) + .await + .map_err(AppCommandError::from)?; + let port = app_metadata_service::get_value(conn, WEB_SERVICE_PORT_KEY) + .await + .map_err(AppCommandError::from)? + .as_deref() + .and_then(|s| s.trim().parse::().ok()); + Ok(WebServiceConfig { token, port }) +} + +/// Stable i18n-key prefixes — the frontend maps these to localized text. +const ERR_ALREADY_RUNNING: &str = "web_server.already_running"; +const ERR_INVALID_ADDRESS: &str = "web_server.invalid_address"; +const ERR_PORT_IN_USE: &str = "web_server.port_in_use"; +const ERR_PERMISSION_DENIED: &str = "web_server.permission_denied"; +const ERR_ADDRESS_UNAVAILABLE: &str = "web_server.address_unavailable"; +const ERR_BIND_FAILED: &str = "web_server.bind_failed"; + +fn classify_bind_error(err: std::io::Error) -> AppCommandError { + use std::io::ErrorKind; + let (code, key) = match err.kind() { + ErrorKind::AddrInUse => (AppErrorCode::AlreadyExists, ERR_PORT_IN_USE), + ErrorKind::PermissionDenied => (AppErrorCode::PermissionDenied, ERR_PERMISSION_DENIED), + ErrorKind::AddrNotAvailable => (AppErrorCode::InvalidInput, ERR_ADDRESS_UNAVAILABLE), + _ => (AppErrorCode::IoError, ERR_BIND_FAILED), + }; + AppCommandError::new(code, key).with_detail(err.to_string()) +} + #[cfg(feature = "tauri-runtime")] pub(crate) fn find_static_dir_tauri(app: &tauri::AppHandle) -> PathBuf { use tauri::Manager; @@ -112,6 +232,27 @@ pub fn find_static_dir_standalone(explicit: Option<&str>) -> PathBuf { find_static_dir_fallback() } +/// RAII guard that resets `running` back to `false` on drop unless disarmed. +/// Used to guarantee the flag is released on any error during start. +struct RunningGuard<'a> { + running: &'a std::sync::atomic::AtomicBool, + armed: bool, +} + +impl<'a> RunningGuard<'a> { + fn disarm(&mut self) { + self.armed = false; + } +} + +impl<'a> Drop for RunningGuard<'a> { + fn drop(&mut self) { + if self.armed { + self.running.store(false, Ordering::Release); + } + } +} + pub fn get_local_addresses(port: u16) -> Vec { let mut addrs = vec![format!("http://127.0.0.1:{}", port)]; // Try to get LAN IPs @@ -134,30 +275,38 @@ pub(crate) async fn do_start_web_server_with_state( static_dir: PathBuf, port: Option, host: Option, + token: Option, ) -> Result { let ws = &app_state.web_server_state; - if ws.running.load(Ordering::Relaxed) { - return Err(AppCommandError::new( - AppErrorCode::AlreadyExists, - "Web server is already running", - )); - } - let port = port.unwrap_or(3080); + // Atomically claim the running flag; concurrent starts see AlreadyExists. + ws.running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| AppCommandError::new(AppErrorCode::AlreadyExists, ERR_ALREADY_RUNNING))?; + let mut guard = RunningGuard { + running: &ws.running, + armed: true, + }; + + let port = resolve_web_service_port(&app_state.db.conn, port).await?; let host = host.unwrap_or_else(|| "0.0.0.0".to_string()); - let token = generate_random_token(); - - let router = router::build_router(app_state.clone(), token.clone(), static_dir); + let token = resolve_web_service_token(&app_state.db.conn, token).await?; let addr: SocketAddr = format!("{}:{}", host, port) .parse() .map_err(|e: std::net::AddrParseError| { - AppCommandError::invalid_input("Invalid host/port").with_detail(e.to_string()) + AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS) + .with_detail(e.to_string()) })?; - let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { - AppCommandError::io_error("Failed to bind address").with_detail(e.to_string()) - })?; + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(classify_bind_error)?; + + // Persist only after a successful bind so a failed attempt doesn't overwrite saved state. + persist_web_service_config(&app_state.db.conn, &token, port).await?; + + let router = router::build_router(app_state.clone(), token.clone(), static_dir); let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port); eprintln!("[WEB] Starting web server on {}", addr); @@ -171,7 +320,8 @@ pub(crate) async fn do_start_web_server_with_state( *ws.handle.lock().unwrap() = Some(handle); ws.port.store(actual_port, Ordering::Relaxed); *ws.token.lock().unwrap() = token.clone(); - ws.running.store(true, Ordering::Relaxed); + // running already true from compare_exchange; disarm guard so it doesn't flip back. + guard.disarm(); let addresses = get_local_addresses(actual_port); Ok(WebServerInfo { @@ -214,6 +364,7 @@ pub async fn start_web_server( state: tauri::State<'_, WebServerState>, port: Option, host: Option, + token: Option, ) -> Result { // In Tauri mode, we still need to start via the legacy path because // the full AppState isn't easily available from tauri::State here. @@ -221,16 +372,34 @@ pub async fn start_web_server( use tauri::Manager; let ws = &*state; - if ws.running.load(Ordering::Relaxed) { - return Err(AppCommandError::new( - AppErrorCode::AlreadyExists, - "Web server is already running", - )); - } - let port_val = port.unwrap_or(3080); + // Atomically claim the running flag; concurrent starts see AlreadyExists. + ws.running + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| AppCommandError::new(AppErrorCode::AlreadyExists, ERR_ALREADY_RUNNING))?; + let mut guard = RunningGuard { + running: &ws.running, + armed: true, + }; + + let db = app.state::(); + let port_val = resolve_web_service_port(&db.conn, port).await?; let host_val = host.unwrap_or_else(|| "0.0.0.0".to_string()); - let token = generate_random_token(); + let token = resolve_web_service_token(&db.conn, token).await?; + + let addr: SocketAddr = format!("{}:{}", host_val, port_val) + .parse() + .map_err(|e: std::net::AddrParseError| { + AppCommandError::new(AppErrorCode::InvalidInput, ERR_INVALID_ADDRESS) + .with_detail(e.to_string()) + })?; + + let listener = tokio::net::TcpListener::bind(addr) + .await + .map_err(classify_bind_error)?; + + // Persist only after a successful bind so a failed attempt doesn't overwrite saved state. + persist_web_service_config(&db.conn, &token, port_val).await?; let static_dir = find_static_dir_tauri(&app); @@ -250,16 +419,6 @@ pub async fn start_web_server( let router = router::build_router(app_state, token.clone(), static_dir); - let addr: SocketAddr = format!("{}:{}", host_val, port_val) - .parse() - .map_err(|e: std::net::AddrParseError| { - AppCommandError::invalid_input("Invalid host/port").with_detail(e.to_string()) - })?; - - let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| { - AppCommandError::io_error("Failed to bind address").with_detail(e.to_string()) - })?; - let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port_val); eprintln!("[WEB] Starting web server on {}", addr); @@ -272,7 +431,8 @@ pub async fn start_web_server( *ws.handle.lock().unwrap() = Some(handle); ws.port.store(actual_port, Ordering::Relaxed); *ws.token.lock().unwrap() = token.clone(); - ws.running.store(true, Ordering::Relaxed); + // running already true from compare_exchange; disarm guard so it doesn't flip back. + guard.disarm(); let addresses = get_local_addresses(actual_port); Ok(WebServerInfo { @@ -298,3 +458,11 @@ pub async fn get_web_server_status( ) -> Result, AppCommandError> { Ok(do_get_web_server_status(&state)) } + +#[cfg(feature = "tauri-runtime")] +#[tauri::command] +pub async fn get_web_service_config( + db: tauri::State<'_, crate::db::AppDatabase>, +) -> Result { + load_web_service_config(&db.conn).await +} diff --git a/src/components/settings/web-service-settings.tsx b/src/components/settings/web-service-settings.tsx index f4365b2..2e1c078 100644 --- a/src/components/settings/web-service-settings.tsx +++ b/src/components/settings/web-service-settings.tsx @@ -1,15 +1,18 @@ "use client" import { useCallback, useEffect, useState } from "react" -import { Check, Copy, ExternalLink, Eye, EyeOff } from "lucide-react" +import { Check, Copy, ExternalLink, Eye, EyeOff, RefreshCw } from "lucide-react" import { useTranslations } from "next-intl" import { ScrollArea } from "@/components/ui/scroll-area" import { startWebServer, stopWebServer, getWebServerStatus, + getWebServiceConfig, type WebServerInfo, } from "@/lib/api" + +const DEFAULT_PORT = 3080 import { openUrl } from "@/lib/platform" function AddressCard({ label, value }: { label: string; value: string }) { @@ -36,29 +39,64 @@ function AddressCard({ label, value }: { label: string; value: string }) { ) } -function TokenCard({ label, value }: { label: string; value: string }) { +function generateRandomToken() { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return crypto.randomUUID().replace(/-/g, "") + } + return Array.from({ length: 32 }, () => + Math.floor(Math.random() * 16).toString(16) + ).join("") +} + +function TokenEditor({ + label, + value, + onChange, + disabled, + placeholder, +}: { + label: string + value: string + onChange: (next: string) => void + disabled: boolean + placeholder: string +}) { const t = useTranslations("WebServiceSettings") const [copied, setCopied] = useState(false) const [revealed, setRevealed] = useState(false) function handleCopy() { + if (!value) return navigator.clipboard.writeText(value) setCopied(true) setTimeout(() => setCopied(false), 1500) } - const displayValue = revealed - ? value - : "\u2022".repeat(Math.max(value.length, 12)) - return (
{label}
- - {displayValue} - + onChange(e.target.value)} + disabled={disabled} + placeholder={placeholder} + spellCheck={false} + autoComplete="off" + className="min-w-0 flex-1 bg-transparent font-mono text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed" + />
+ {!disabled && ( + + )}
+ {/* Token config */} + +

{t("tokenHint")}

+ {/* Start/Stop button */}
@@ -194,7 +275,7 @@ export function WebServiceSettings() { {error &&

{error}

} - {/* Connection info */} + {/* Addresses (only when running) */} {isRunning && (
{status.addresses.map((addr) => ( @@ -204,8 +285,6 @@ export function WebServiceSettings() { value={addr} /> ))} - -

{t("tokenHint")}

)}
diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 094cedd..82d1a07 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1857,7 +1857,17 @@ "copy": "نسخ", "addressLabel": "عنوان الوصول", "tokenLabel": "رمز الوصول", - "tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة" + "tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة", + "tokenPlaceholder": "اتركه فارغاً للتوليد التلقائي", + "regenerate": "إعادة التوليد", + "errors": { + "alreadyRunning": "خدمة الويب قيد التشغيل بالفعل", + "invalidAddress": "تنسيق المضيف أو المنفذ غير صالح", + "portInUse": "المنفذ {port} مستخدم بالفعل. أغلق العملية التي تستخدمه أو اختر منفذاً آخر.", + "permissionDenied": "الصلاحيات غير كافية. استخدم منفذاً أعلى من 1024 أو شغّل التطبيق بصلاحيات أعلى.", + "addressUnavailable": "هذا العنوان غير متاح على هذا الجهاز", + "bindFailed": "فشل ربط العنوان" + } }, "DirectoryBrowser": { "title": "تصفح المجلد", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index b60ded9..95dafcf 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1857,7 +1857,17 @@ "copy": "Kopieren", "addressLabel": "Zugriffsadresse", "tokenLabel": "Zugriffstoken", - "tokenHint": "Geben Sie dieses Token beim ersten Zugriff auf den Web-Client ein" + "tokenHint": "Geben Sie dieses Token beim ersten Zugriff auf den Web-Client ein", + "tokenPlaceholder": "Leer lassen für automatische Generierung", + "regenerate": "Neu generieren", + "errors": { + "alreadyRunning": "Der Web-Dienst läuft bereits", + "invalidAddress": "Host- oder Portformat ungültig", + "portInUse": "Port {port} wird bereits verwendet. Beenden Sie den Prozess oder wählen Sie einen anderen Port.", + "permissionDenied": "Zugriff verweigert. Verwenden Sie einen Port über 1024 oder starten Sie mit höheren Rechten.", + "addressUnavailable": "Die Adresse ist auf diesem Computer nicht verfügbar", + "bindFailed": "Adresse konnte nicht gebunden werden" + } }, "DirectoryBrowser": { "title": "Verzeichnis durchsuchen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 08125ea..93fd548 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1857,7 +1857,17 @@ "copy": "Copy", "addressLabel": "Access Address", "tokenLabel": "Access Token", - "tokenHint": "Enter this token when accessing the Web client for the first time" + "tokenHint": "Enter this token when accessing the Web client for the first time", + "tokenPlaceholder": "Leave empty to auto-generate", + "regenerate": "Regenerate", + "errors": { + "alreadyRunning": "Web service is already running", + "invalidAddress": "Invalid host or port format", + "portInUse": "Port {port} is already in use. Close the process using it or choose another port.", + "permissionDenied": "Permission denied. Try a port above 1024 or run with higher privileges.", + "addressUnavailable": "The address is not available on this machine", + "bindFailed": "Failed to bind address" + } }, "DirectoryBrowser": { "title": "Browse Directory", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 5a248f9..f4b808e 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1857,7 +1857,17 @@ "copy": "Copiar", "addressLabel": "Dirección de acceso", "tokenLabel": "Token de acceso", - "tokenHint": "Ingrese este token al acceder al cliente Web por primera vez" + "tokenHint": "Ingrese este token al acceder al cliente Web por primera vez", + "tokenPlaceholder": "Dejar vacío para generar automáticamente", + "regenerate": "Regenerar", + "errors": { + "alreadyRunning": "El servicio Web ya está en ejecución", + "invalidAddress": "Formato de host o puerto no válido", + "portInUse": "El puerto {port} ya está en uso. Cierre el proceso que lo usa o elija otro puerto.", + "permissionDenied": "Permiso denegado. Utilice un puerto superior a 1024 o ejecute con mayores privilegios.", + "addressUnavailable": "La dirección no está disponible en este equipo", + "bindFailed": "No se pudo enlazar la dirección" + } }, "DirectoryBrowser": { "title": "Explorar directorio", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 238781d..5fdd9d3 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1857,7 +1857,17 @@ "copy": "Copier", "addressLabel": "Adresse d'accès", "tokenLabel": "Token d'accès", - "tokenHint": "Entrez ce token lors du premier accès au client Web" + "tokenHint": "Entrez ce token lors du premier accès au client Web", + "tokenPlaceholder": "Laisser vide pour générer automatiquement", + "regenerate": "Régénérer", + "errors": { + "alreadyRunning": "Le service Web est déjà en cours d'exécution", + "invalidAddress": "Format d'hôte ou de port non valide", + "portInUse": "Le port {port} est déjà utilisé. Fermez le processus qui l'utilise ou choisissez un autre port.", + "permissionDenied": "Permission refusée. Utilisez un port supérieur à 1024 ou exécutez avec des privilèges plus élevés.", + "addressUnavailable": "Cette adresse n'est pas disponible sur cette machine", + "bindFailed": "Échec de la liaison à l'adresse" + } }, "DirectoryBrowser": { "title": "Parcourir le répertoire", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index e8dd6dd..8ec5ae3 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1857,7 +1857,17 @@ "copy": "コピー", "addressLabel": "アクセスアドレス", "tokenLabel": "アクセストークン", - "tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください" + "tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください", + "tokenPlaceholder": "空欄の場合は自動生成", + "regenerate": "再生成", + "errors": { + "alreadyRunning": "Web サービスはすでに起動しています", + "invalidAddress": "ホストまたはポートの形式が無効です", + "portInUse": "ポート {port} はすでに使用中です。そのポートを使用しているプロセスを終了するか、別のポートを指定してください", + "permissionDenied": "権限が不足しています。1024 以上のポートを使用するか、より高い権限で実行してください", + "addressUnavailable": "このアドレスはこの端末では利用できません", + "bindFailed": "アドレスのバインドに失敗しました" + } }, "DirectoryBrowser": { "title": "ディレクトリを参照", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 0fcd6cb..7c414b6 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1857,7 +1857,17 @@ "copy": "복사", "addressLabel": "접속 주소", "tokenLabel": "접속 토큰", - "tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요" + "tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요", + "tokenPlaceholder": "비워두면 자동 생성", + "regenerate": "재생성", + "errors": { + "alreadyRunning": "웹 서비스가 이미 실행 중입니다", + "invalidAddress": "호스트 또는 포트 형식이 올바르지 않습니다", + "portInUse": "포트 {port}가 이미 사용 중입니다. 해당 포트를 사용 중인 프로세스를 종료하거나 다른 포트를 선택하세요", + "permissionDenied": "권한이 부족합니다. 1024 이상의 포트를 사용하거나 더 높은 권한으로 실행하세요", + "addressUnavailable": "이 주소는 현재 시스템에서 사용할 수 없습니다", + "bindFailed": "주소 바인딩에 실패했습니다" + } }, "DirectoryBrowser": { "title": "디렉토리 찾아보기", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 8635d98..cfaaa29 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1857,7 +1857,17 @@ "copy": "Copiar", "addressLabel": "Endereço de acesso", "tokenLabel": "Token de acesso", - "tokenHint": "Insira este token ao acessar o cliente Web pela primeira vez" + "tokenHint": "Insira este token ao acessar o cliente Web pela primeira vez", + "tokenPlaceholder": "Deixe em branco para gerar automaticamente", + "regenerate": "Regenerar", + "errors": { + "alreadyRunning": "O serviço Web já está em execução", + "invalidAddress": "Formato de host ou porta inválido", + "portInUse": "A porta {port} já está em uso. Feche o processo que a utiliza ou escolha outra porta.", + "permissionDenied": "Permissão negada. Use uma porta acima de 1024 ou execute com privilégios mais altos.", + "addressUnavailable": "O endereço não está disponível nesta máquina", + "bindFailed": "Falha ao vincular o endereço" + } }, "DirectoryBrowser": { "title": "Explorar diretório", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 1d5f0e6..9ce6da9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1857,7 +1857,17 @@ "copy": "复制", "addressLabel": "访问地址", "tokenLabel": "访问 Token", - "tokenHint": "Web 客户端首次访问时需输入此 Token" + "tokenHint": "Web 客户端首次访问时需输入此 Token", + "tokenPlaceholder": "留空则自动生成", + "regenerate": "重新生成", + "errors": { + "alreadyRunning": "Web 服务已在运行", + "invalidAddress": "主机或端口格式无效", + "portInUse": "端口 {port} 已被占用,请关闭占用该端口的程序或更换其他端口", + "permissionDenied": "权限不足,请使用 1024 以上的端口,或以更高权限运行", + "addressUnavailable": "该地址在本机不可用", + "bindFailed": "绑定地址失败" + } }, "DirectoryBrowser": { "title": "浏览目录", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 68f83f7..2da6fd6 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1857,7 +1857,17 @@ "copy": "複製", "addressLabel": "存取位址", "tokenLabel": "存取 Token", - "tokenHint": "Web 用戶端首次存取時需輸入此 Token" + "tokenHint": "Web 用戶端首次存取時需輸入此 Token", + "tokenPlaceholder": "留空則自動產生", + "regenerate": "重新產生", + "errors": { + "alreadyRunning": "Web 服務已在執行", + "invalidAddress": "主機或連接埠格式無效", + "portInUse": "連接埠 {port} 已被佔用,請關閉佔用的程式或改用其他連接埠", + "permissionDenied": "權限不足,請使用 1024 以上的連接埠,或以更高權限執行", + "addressUnavailable": "該位址在本機不可用", + "bindFailed": "綁定位址失敗" + } }, "DirectoryBrowser": { "title": "瀏覽目錄", diff --git a/src/lib/api.ts b/src/lib/api.ts index 6550829..8c025ea 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1477,10 +1477,12 @@ export interface WebServerInfo { export async function startWebServer(params?: { port?: number host?: string + token?: string | null }): Promise { return getTransport().call("start_web_server", { port: params?.port ?? null, host: params?.host ?? null, + token: params?.token ?? null, }) } @@ -1492,6 +1494,15 @@ export async function getWebServerStatus(): Promise { return getTransport().call("get_web_server_status") } +export interface WebServiceConfig { + token: string | null + port: number | null +} + +export async function getWebServiceConfig(): Promise { + return getTransport().call("get_web_service_config") +} + // ─── Chat Channels ─── export async function listChatChannels(): Promise {