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
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use sea_orm::sea_query::OnConflict;
|
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 sea_orm::{ActiveValue::NotSet, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
|
||||||
use crate::db::entities::app_metadata;
|
use crate::db::entities::app_metadata;
|
||||||
use crate::db::error::DbError;
|
use crate::db::error::DbError;
|
||||||
|
|
||||||
pub async fn upsert_value(
|
pub async fn upsert_value<C: ConnectionTrait>(
|
||||||
conn: &DatabaseConnection,
|
conn: &C,
|
||||||
key: &str,
|
key: &str,
|
||||||
value: &str,
|
value: &str,
|
||||||
) -> Result<(), DbError> {
|
) -> Result<(), DbError> {
|
||||||
|
|||||||
@@ -449,6 +449,7 @@ mod tauri_app {
|
|||||||
web::start_web_server,
|
web::start_web_server,
|
||||||
web::stop_web_server,
|
web::stop_web_server,
|
||||||
web::get_web_server_status,
|
web::get_web_server_status,
|
||||||
|
web::get_web_service_config,
|
||||||
])
|
])
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub async fn get_web_server_status(
|
|||||||
pub struct StartWebServerParams {
|
pub struct StartWebServerParams {
|
||||||
pub port: Option<u16>,
|
pub port: Option<u16>,
|
||||||
pub host: Option<String>,
|
pub host: Option<String>,
|
||||||
|
pub token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_web_server(
|
pub async fn start_web_server(
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ use std::path::PathBuf;
|
|||||||
use std::sync::atomic::{AtomicU16, Ordering};
|
use std::sync::atomic::{AtomicU16, Ordering};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use sea_orm::{DatabaseConnection, TransactionError, TransactionTrait};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::app_error::{AppCommandError, AppErrorCode};
|
use crate::app_error::{AppCommandError, AppErrorCode};
|
||||||
use crate::app_state::AppState;
|
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 {
|
pub struct WebServerState {
|
||||||
handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||||
@@ -50,6 +56,120 @@ pub fn generate_random_token() -> String {
|
|||||||
uuid::Uuid::new_v4().to_string().replace('-', "")
|
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<String>,
|
||||||
|
) -> Result<String, AppCommandError> {
|
||||||
|
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<u16>,
|
||||||
|
) -> Result<u16, AppCommandError> {
|
||||||
|
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::<u16>().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<AppCommandError>| 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<String>,
|
||||||
|
pub port: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn load_web_service_config(
|
||||||
|
conn: &DatabaseConnection,
|
||||||
|
) -> Result<WebServiceConfig, AppCommandError> {
|
||||||
|
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::<u16>().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")]
|
#[cfg(feature = "tauri-runtime")]
|
||||||
pub(crate) fn find_static_dir_tauri(app: &tauri::AppHandle) -> PathBuf {
|
pub(crate) fn find_static_dir_tauri(app: &tauri::AppHandle) -> PathBuf {
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
@@ -112,6 +232,27 @@ pub fn find_static_dir_standalone(explicit: Option<&str>) -> PathBuf {
|
|||||||
find_static_dir_fallback()
|
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<String> {
|
pub fn get_local_addresses(port: u16) -> Vec<String> {
|
||||||
let mut addrs = vec![format!("http://127.0.0.1:{}", port)];
|
let mut addrs = vec![format!("http://127.0.0.1:{}", port)];
|
||||||
// Try to get LAN IPs
|
// Try to get LAN IPs
|
||||||
@@ -134,30 +275,38 @@ pub(crate) async fn do_start_web_server_with_state(
|
|||||||
static_dir: PathBuf,
|
static_dir: PathBuf,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
|
token: Option<String>,
|
||||||
) -> Result<WebServerInfo, AppCommandError> {
|
) -> Result<WebServerInfo, AppCommandError> {
|
||||||
let ws = &app_state.web_server_state;
|
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 host = host.unwrap_or_else(|| "0.0.0.0".to_string());
|
||||||
let token = generate_random_token();
|
let token = resolve_web_service_token(&app_state.db.conn, token).await?;
|
||||||
|
|
||||||
let router = router::build_router(app_state.clone(), token.clone(), static_dir);
|
|
||||||
|
|
||||||
let addr: SocketAddr = format!("{}:{}", host, port)
|
let addr: SocketAddr = format!("{}:{}", host, port)
|
||||||
.parse()
|
.parse()
|
||||||
.map_err(|e: std::net::AddrParseError| {
|
.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| {
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
AppCommandError::io_error("Failed to bind address").with_detail(e.to_string())
|
.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);
|
let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||||
eprintln!("[WEB] Starting web server on {}", addr);
|
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.handle.lock().unwrap() = Some(handle);
|
||||||
ws.port.store(actual_port, Ordering::Relaxed);
|
ws.port.store(actual_port, Ordering::Relaxed);
|
||||||
*ws.token.lock().unwrap() = token.clone();
|
*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);
|
let addresses = get_local_addresses(actual_port);
|
||||||
Ok(WebServerInfo {
|
Ok(WebServerInfo {
|
||||||
@@ -214,6 +364,7 @@ pub async fn start_web_server(
|
|||||||
state: tauri::State<'_, WebServerState>,
|
state: tauri::State<'_, WebServerState>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
|
token: Option<String>,
|
||||||
) -> Result<WebServerInfo, AppCommandError> {
|
) -> Result<WebServerInfo, AppCommandError> {
|
||||||
// In Tauri mode, we still need to start via the legacy path because
|
// In Tauri mode, we still need to start via the legacy path because
|
||||||
// the full AppState isn't easily available from tauri::State here.
|
// the full AppState isn't easily available from tauri::State here.
|
||||||
@@ -221,16 +372,34 @@ pub async fn start_web_server(
|
|||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
|
||||||
let ws = &*state;
|
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::<crate::db::AppDatabase>();
|
||||||
|
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 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);
|
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 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);
|
let actual_port = listener.local_addr().map(|a| a.port()).unwrap_or(port_val);
|
||||||
eprintln!("[WEB] Starting web server on {}", addr);
|
eprintln!("[WEB] Starting web server on {}", addr);
|
||||||
|
|
||||||
@@ -272,7 +431,8 @@ pub async fn start_web_server(
|
|||||||
*ws.handle.lock().unwrap() = Some(handle);
|
*ws.handle.lock().unwrap() = Some(handle);
|
||||||
ws.port.store(actual_port, Ordering::Relaxed);
|
ws.port.store(actual_port, Ordering::Relaxed);
|
||||||
*ws.token.lock().unwrap() = token.clone();
|
*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);
|
let addresses = get_local_addresses(actual_port);
|
||||||
Ok(WebServerInfo {
|
Ok(WebServerInfo {
|
||||||
@@ -298,3 +458,11 @@ pub async fn get_web_server_status(
|
|||||||
) -> Result<Option<WebServerInfo>, AppCommandError> {
|
) -> Result<Option<WebServerInfo>, AppCommandError> {
|
||||||
Ok(do_get_web_server_status(&state))
|
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<WebServiceConfig, AppCommandError> {
|
||||||
|
load_web_service_config(&db.conn).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from "react"
|
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 { useTranslations } from "next-intl"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import {
|
import {
|
||||||
startWebServer,
|
startWebServer,
|
||||||
stopWebServer,
|
stopWebServer,
|
||||||
getWebServerStatus,
|
getWebServerStatus,
|
||||||
|
getWebServiceConfig,
|
||||||
type WebServerInfo,
|
type WebServerInfo,
|
||||||
} from "@/lib/api"
|
} from "@/lib/api"
|
||||||
|
|
||||||
|
const DEFAULT_PORT = 3080
|
||||||
import { openUrl } from "@/lib/platform"
|
import { openUrl } from "@/lib/platform"
|
||||||
|
|
||||||
function AddressCard({ label, value }: { label: string; value: string }) {
|
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 t = useTranslations("WebServiceSettings")
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [revealed, setRevealed] = useState(false)
|
const [revealed, setRevealed] = useState(false)
|
||||||
|
|
||||||
function handleCopy() {
|
function handleCopy() {
|
||||||
|
if (!value) return
|
||||||
navigator.clipboard.writeText(value)
|
navigator.clipboard.writeText(value)
|
||||||
setCopied(true)
|
setCopied(true)
|
||||||
setTimeout(() => setCopied(false), 1500)
|
setTimeout(() => setCopied(false), 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const displayValue = revealed
|
|
||||||
? value
|
|
||||||
: "\u2022".repeat(Math.max(value.length, 12))
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||||
<div className="group relative flex items-center rounded-md border bg-muted/40 px-3 py-2">
|
<div className="group relative flex items-center rounded-md border bg-muted/40 px-3 py-2">
|
||||||
<code className="min-w-0 flex-1 truncate text-sm select-all">
|
<input
|
||||||
{displayValue}
|
type={revealed ? "text" : "password"}
|
||||||
</code>
|
value={value}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
<div className="ml-2 flex shrink-0 items-center gap-1">
|
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(generateRandomToken())}
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
|
title={t("regenerate")}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRevealed((v) => !v)}
|
onClick={() => setRevealed((v) => !v)}
|
||||||
@@ -74,7 +112,8 @@ function TokenCard({ label, value }: { label: string; value: string }) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
disabled={!value}
|
||||||
|
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
title={t("copy")}
|
title={t("copy")}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
@@ -92,16 +131,26 @@ function TokenCard({ label, value }: { label: string; value: string }) {
|
|||||||
export function WebServiceSettings() {
|
export function WebServiceSettings() {
|
||||||
const t = useTranslations("WebServiceSettings")
|
const t = useTranslations("WebServiceSettings")
|
||||||
const [status, setStatus] = useState<WebServerInfo | null>(null)
|
const [status, setStatus] = useState<WebServerInfo | null>(null)
|
||||||
const [port, setPort] = useState("3080")
|
const [port, setPort] = useState(String(DEFAULT_PORT))
|
||||||
|
const [token, setToken] = useState("")
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState("")
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
const fetchStatus = useCallback(async () => {
|
const fetchStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const info = await getWebServerStatus()
|
const [info, savedConfig] = await Promise.all([
|
||||||
|
getWebServerStatus(),
|
||||||
|
getWebServiceConfig().catch(() => ({ token: null, port: null })),
|
||||||
|
])
|
||||||
setStatus(info)
|
setStatus(info)
|
||||||
if (info) {
|
if (info) {
|
||||||
setPort(String(info.port))
|
setPort(String(info.port))
|
||||||
|
setToken(info.token)
|
||||||
|
} else {
|
||||||
|
setPort(String(savedConfig.port ?? DEFAULT_PORT))
|
||||||
|
if (savedConfig.token) {
|
||||||
|
setToken(savedConfig.token)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Server status unavailable
|
// Server status unavailable
|
||||||
@@ -112,20 +161,42 @@ export function WebServiceSettings() {
|
|||||||
fetchStatus()
|
fetchStatus()
|
||||||
}, [fetchStatus])
|
}, [fetchStatus])
|
||||||
|
|
||||||
|
const startErrorKeys: Record<string, string> = {
|
||||||
|
"web_server.already_running": "errors.alreadyRunning",
|
||||||
|
"web_server.invalid_address": "errors.invalidAddress",
|
||||||
|
"web_server.port_in_use": "errors.portInUse",
|
||||||
|
"web_server.permission_denied": "errors.permissionDenied",
|
||||||
|
"web_server.address_unavailable": "errors.addressUnavailable",
|
||||||
|
"web_server.bind_failed": "errors.bindFailed",
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
setError("")
|
setError("")
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
const portNum = parseInt(port, 10) || DEFAULT_PORT
|
||||||
const info = await startWebServer({
|
const info = await startWebServer({
|
||||||
port: parseInt(port, 10) || 3080,
|
port: portNum,
|
||||||
|
token: token.trim() || null,
|
||||||
})
|
})
|
||||||
setStatus(info)
|
setStatus(info)
|
||||||
|
setToken(info.token)
|
||||||
|
setPort(String(info.port))
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg =
|
const rawMsg =
|
||||||
e && typeof e === "object" && "message" in e
|
e && typeof e === "object" && "message" in e
|
||||||
? (e as { message: string }).message
|
? String((e as { message: string }).message)
|
||||||
: t("startFailed")
|
: ""
|
||||||
setError(msg)
|
const localKey = startErrorKeys[rawMsg]
|
||||||
|
if (localKey) {
|
||||||
|
setError(
|
||||||
|
t(localKey as Parameters<typeof t>[0], {
|
||||||
|
port: parseInt(port, 10) || DEFAULT_PORT,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
setError(rawMsg || t("startFailed"))
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -170,6 +241,16 @@ export function WebServiceSettings() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Token config */}
|
||||||
|
<TokenEditor
|
||||||
|
label={t("tokenLabel")}
|
||||||
|
value={token}
|
||||||
|
onChange={setToken}
|
||||||
|
disabled={isRunning}
|
||||||
|
placeholder={t("tokenPlaceholder")}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("tokenHint")}</p>
|
||||||
|
|
||||||
{/* Start/Stop button */}
|
{/* Start/Stop button */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<label className="w-20 text-sm font-medium">{t("status")}</label>
|
<label className="w-20 text-sm font-medium">{t("status")}</label>
|
||||||
@@ -194,7 +275,7 @@ export function WebServiceSettings() {
|
|||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
{/* Connection info */}
|
{/* Addresses (only when running) */}
|
||||||
{isRunning && (
|
{isRunning && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{status.addresses.map((addr) => (
|
{status.addresses.map((addr) => (
|
||||||
@@ -204,8 +285,6 @@ export function WebServiceSettings() {
|
|||||||
value={addr}
|
value={addr}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<TokenCard label={t("tokenLabel")} value={status.token} />
|
|
||||||
<p className="text-xs text-muted-foreground">{t("tokenHint")}</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "نسخ",
|
"copy": "نسخ",
|
||||||
"addressLabel": "عنوان الوصول",
|
"addressLabel": "عنوان الوصول",
|
||||||
"tokenLabel": "رمز الوصول",
|
"tokenLabel": "رمز الوصول",
|
||||||
"tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة"
|
"tokenHint": "أدخل هذا الرمز عند الوصول إلى عميل الويب لأول مرة",
|
||||||
|
"tokenPlaceholder": "اتركه فارغاً للتوليد التلقائي",
|
||||||
|
"regenerate": "إعادة التوليد",
|
||||||
|
"errors": {
|
||||||
|
"alreadyRunning": "خدمة الويب قيد التشغيل بالفعل",
|
||||||
|
"invalidAddress": "تنسيق المضيف أو المنفذ غير صالح",
|
||||||
|
"portInUse": "المنفذ {port} مستخدم بالفعل. أغلق العملية التي تستخدمه أو اختر منفذاً آخر.",
|
||||||
|
"permissionDenied": "الصلاحيات غير كافية. استخدم منفذاً أعلى من 1024 أو شغّل التطبيق بصلاحيات أعلى.",
|
||||||
|
"addressUnavailable": "هذا العنوان غير متاح على هذا الجهاز",
|
||||||
|
"bindFailed": "فشل ربط العنوان"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"DirectoryBrowser": {
|
"DirectoryBrowser": {
|
||||||
"title": "تصفح المجلد",
|
"title": "تصفح المجلد",
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "Kopieren",
|
"copy": "Kopieren",
|
||||||
"addressLabel": "Zugriffsadresse",
|
"addressLabel": "Zugriffsadresse",
|
||||||
"tokenLabel": "Zugriffstoken",
|
"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": {
|
"DirectoryBrowser": {
|
||||||
"title": "Verzeichnis durchsuchen",
|
"title": "Verzeichnis durchsuchen",
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"addressLabel": "Access Address",
|
"addressLabel": "Access Address",
|
||||||
"tokenLabel": "Access Token",
|
"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": {
|
"DirectoryBrowser": {
|
||||||
"title": "Browse Directory",
|
"title": "Browse Directory",
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"addressLabel": "Dirección de acceso",
|
"addressLabel": "Dirección de acceso",
|
||||||
"tokenLabel": "Token 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": {
|
"DirectoryBrowser": {
|
||||||
"title": "Explorar directorio",
|
"title": "Explorar directorio",
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "Copier",
|
"copy": "Copier",
|
||||||
"addressLabel": "Adresse d'accès",
|
"addressLabel": "Adresse d'accès",
|
||||||
"tokenLabel": "Token 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": {
|
"DirectoryBrowser": {
|
||||||
"title": "Parcourir le répertoire",
|
"title": "Parcourir le répertoire",
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "コピー",
|
"copy": "コピー",
|
||||||
"addressLabel": "アクセスアドレス",
|
"addressLabel": "アクセスアドレス",
|
||||||
"tokenLabel": "アクセストークン",
|
"tokenLabel": "アクセストークン",
|
||||||
"tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください"
|
"tokenHint": "Webクライアントの初回アクセス時にこのトークンを入力してください",
|
||||||
|
"tokenPlaceholder": "空欄の場合は自動生成",
|
||||||
|
"regenerate": "再生成",
|
||||||
|
"errors": {
|
||||||
|
"alreadyRunning": "Web サービスはすでに起動しています",
|
||||||
|
"invalidAddress": "ホストまたはポートの形式が無効です",
|
||||||
|
"portInUse": "ポート {port} はすでに使用中です。そのポートを使用しているプロセスを終了するか、別のポートを指定してください",
|
||||||
|
"permissionDenied": "権限が不足しています。1024 以上のポートを使用するか、より高い権限で実行してください",
|
||||||
|
"addressUnavailable": "このアドレスはこの端末では利用できません",
|
||||||
|
"bindFailed": "アドレスのバインドに失敗しました"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"DirectoryBrowser": {
|
"DirectoryBrowser": {
|
||||||
"title": "ディレクトリを参照",
|
"title": "ディレクトリを参照",
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "복사",
|
"copy": "복사",
|
||||||
"addressLabel": "접속 주소",
|
"addressLabel": "접속 주소",
|
||||||
"tokenLabel": "접속 토큰",
|
"tokenLabel": "접속 토큰",
|
||||||
"tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요"
|
"tokenHint": "웹 클라이언트 첫 접속 시 이 토큰을 입력하세요",
|
||||||
|
"tokenPlaceholder": "비워두면 자동 생성",
|
||||||
|
"regenerate": "재생성",
|
||||||
|
"errors": {
|
||||||
|
"alreadyRunning": "웹 서비스가 이미 실행 중입니다",
|
||||||
|
"invalidAddress": "호스트 또는 포트 형식이 올바르지 않습니다",
|
||||||
|
"portInUse": "포트 {port}가 이미 사용 중입니다. 해당 포트를 사용 중인 프로세스를 종료하거나 다른 포트를 선택하세요",
|
||||||
|
"permissionDenied": "권한이 부족합니다. 1024 이상의 포트를 사용하거나 더 높은 권한으로 실행하세요",
|
||||||
|
"addressUnavailable": "이 주소는 현재 시스템에서 사용할 수 없습니다",
|
||||||
|
"bindFailed": "주소 바인딩에 실패했습니다"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"DirectoryBrowser": {
|
"DirectoryBrowser": {
|
||||||
"title": "디렉토리 찾아보기",
|
"title": "디렉토리 찾아보기",
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "Copiar",
|
"copy": "Copiar",
|
||||||
"addressLabel": "Endereço de acesso",
|
"addressLabel": "Endereço de acesso",
|
||||||
"tokenLabel": "Token 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": {
|
"DirectoryBrowser": {
|
||||||
"title": "Explorar diretório",
|
"title": "Explorar diretório",
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "复制",
|
"copy": "复制",
|
||||||
"addressLabel": "访问地址",
|
"addressLabel": "访问地址",
|
||||||
"tokenLabel": "访问 Token",
|
"tokenLabel": "访问 Token",
|
||||||
"tokenHint": "Web 客户端首次访问时需输入此 Token"
|
"tokenHint": "Web 客户端首次访问时需输入此 Token",
|
||||||
|
"tokenPlaceholder": "留空则自动生成",
|
||||||
|
"regenerate": "重新生成",
|
||||||
|
"errors": {
|
||||||
|
"alreadyRunning": "Web 服务已在运行",
|
||||||
|
"invalidAddress": "主机或端口格式无效",
|
||||||
|
"portInUse": "端口 {port} 已被占用,请关闭占用该端口的程序或更换其他端口",
|
||||||
|
"permissionDenied": "权限不足,请使用 1024 以上的端口,或以更高权限运行",
|
||||||
|
"addressUnavailable": "该地址在本机不可用",
|
||||||
|
"bindFailed": "绑定地址失败"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"DirectoryBrowser": {
|
"DirectoryBrowser": {
|
||||||
"title": "浏览目录",
|
"title": "浏览目录",
|
||||||
|
|||||||
@@ -1857,7 +1857,17 @@
|
|||||||
"copy": "複製",
|
"copy": "複製",
|
||||||
"addressLabel": "存取位址",
|
"addressLabel": "存取位址",
|
||||||
"tokenLabel": "存取 Token",
|
"tokenLabel": "存取 Token",
|
||||||
"tokenHint": "Web 用戶端首次存取時需輸入此 Token"
|
"tokenHint": "Web 用戶端首次存取時需輸入此 Token",
|
||||||
|
"tokenPlaceholder": "留空則自動產生",
|
||||||
|
"regenerate": "重新產生",
|
||||||
|
"errors": {
|
||||||
|
"alreadyRunning": "Web 服務已在執行",
|
||||||
|
"invalidAddress": "主機或連接埠格式無效",
|
||||||
|
"portInUse": "連接埠 {port} 已被佔用,請關閉佔用的程式或改用其他連接埠",
|
||||||
|
"permissionDenied": "權限不足,請使用 1024 以上的連接埠,或以更高權限執行",
|
||||||
|
"addressUnavailable": "該位址在本機不可用",
|
||||||
|
"bindFailed": "綁定位址失敗"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"DirectoryBrowser": {
|
"DirectoryBrowser": {
|
||||||
"title": "瀏覽目錄",
|
"title": "瀏覽目錄",
|
||||||
|
|||||||
@@ -1477,10 +1477,12 @@ export interface WebServerInfo {
|
|||||||
export async function startWebServer(params?: {
|
export async function startWebServer(params?: {
|
||||||
port?: number
|
port?: number
|
||||||
host?: string
|
host?: string
|
||||||
|
token?: string | null
|
||||||
}): Promise<WebServerInfo> {
|
}): Promise<WebServerInfo> {
|
||||||
return getTransport().call("start_web_server", {
|
return getTransport().call("start_web_server", {
|
||||||
port: params?.port ?? null,
|
port: params?.port ?? null,
|
||||||
host: params?.host ?? null,
|
host: params?.host ?? null,
|
||||||
|
token: params?.token ?? null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,6 +1494,15 @@ export async function getWebServerStatus(): Promise<WebServerInfo | null> {
|
|||||||
return getTransport().call("get_web_server_status")
|
return getTransport().call("get_web_server_status")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WebServiceConfig {
|
||||||
|
token: string | null
|
||||||
|
port: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getWebServiceConfig(): Promise<WebServiceConfig> {
|
||||||
|
return getTransport().call("get_web_service_config")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Chat Channels ───
|
// ─── Chat Channels ───
|
||||||
|
|
||||||
export async function listChatChannels(): Promise<ChatChannelInfo[]> {
|
export async function listChatChannels(): Promise<ChatChannelInfo[]> {
|
||||||
|
|||||||
Reference in New Issue
Block a user