Dev (#164)
* chore(release): stop publishing legacy Windows update packages * feat(agents): support OpenCode Desktop across platforms Detect CLI and desktop installations on Windows, macOS, and Linux, and add separate launch and restart controls. Closes #156 * fix(codex): align model display names and show total session count - preserve runtime model identity in generated catalogs - count distinct sessions across database and rollout sources - update session count labels in all supported languages * fix(codex): keep config and auth files in sync - generate auth.json for API key configuration - preserve and validate OAuth credentials - include auth.json in transactional updates and rollback - retain compatibility with legacy Codex configuration states * fix(cpa-gui): update version to 0.2.59 in Cargo files * fix(codex): preserve OAuth mode after closing configuration * feat(config): add TLS management and protocol-aware local URLs - add TLS controls to advanced settings - persist certificate and private key paths - restart the core after TLS changes - switch WebUI, API and management URLs between HTTP and HTTPS - synchronize agent configurations and detection with the active protocol * refactor(config): reorganize network, routing, and TLS settings - split network settings and session routing into separate tabs - move TLS configuration into network settings - preserve TLS restart and HTTPS URL synchronization behavior * feat(config): add independent network settings and custom listen IP * fix(navigation): allow access to usage records while core is stopped * refactor(home): show a single API URL based on the configured listen IP * feat(config): add file pickers for TLS certificate and private key
This commit is contained in:
1 parent
c3cd57bd8e
commit
2b9ceb11fb
21 files changed
+1338
-244
No files matched your search
@@ -582,12 +582,14 @@ pub(crate) async fn fetch_agent_models(
|
||||
if port == 0 {
|
||||
return Err("内核端口无效".to_string());
|
||||
}
|
||||
let tls_enabled = managed_core_tls_enabled();
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(3))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(tls_enabled)
|
||||
.build()
|
||||
.map_err(|error| format!("创建模型列表客户端失败: {error}"))?;
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let base_url = managed_core_loopback_origin(port);
|
||||
let endpoints = [
|
||||
format!("{base_url}/v1/models"),
|
||||
format!("{base_url}/models"),
|
||||
@@ -633,12 +635,14 @@ pub(crate) async fn fetch_codex_runtime_models(
|
||||
if port == 0 {
|
||||
return Err("内核端口无效".to_string());
|
||||
}
|
||||
let tls_enabled = managed_core_tls_enabled();
|
||||
let client = reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(3))
|
||||
.timeout(Duration::from_secs(15))
|
||||
.danger_accept_invalid_certs(tls_enabled)
|
||||
.build()
|
||||
.map_err(|error| format!("创建 Codex 模型列表客户端失败: {error}"))?;
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let base_url = managed_core_loopback_origin(port);
|
||||
let endpoints = [
|
||||
format!("{base_url}/v1/models"),
|
||||
format!("{base_url}/models"),
|
||||
|
||||
@@ -42,7 +42,7 @@ pub(crate) fn build_agent_updates_with_oauth(
|
||||
claude_desktop_model_mappings,
|
||||
} = options;
|
||||
let paths = agent_config_paths(client, home);
|
||||
let root_base = format!("http://127.0.0.1:{port}");
|
||||
let root_base = managed_core_loopback_origin(port);
|
||||
let openai_base = format!("{root_base}/v1");
|
||||
match client {
|
||||
AgentClient::ClaudeCode => {
|
||||
|
||||
@@ -356,7 +356,7 @@ pub(crate) fn inspect_pi_provider_status(
|
||||
let object = root
|
||||
.as_object()
|
||||
.ok_or_else(|| "Pi CLIProxyAPI 配置根节点必须是 JSON 对象".to_string())?;
|
||||
let expected_base_url = format!("http://127.0.0.1:{port}");
|
||||
let expected_base_url = managed_core_loopback_origin(port);
|
||||
credentials_match = object
|
||||
.get("baseUrl")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
@@ -491,7 +491,7 @@ pub(crate) fn repair_pi_provider_inner(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let base_url = format!("http://127.0.0.1:{port}");
|
||||
let base_url = managed_core_loopback_origin(port);
|
||||
let rendered = build_pi_provider_config(existing.as_deref(), &base_url, api_key)?;
|
||||
if existing.as_deref() != Some(rendered.as_str()) {
|
||||
write_bytes_atomically(&config_path, rendered.as_bytes())?;
|
||||
@@ -1365,7 +1365,31 @@ pub(crate) fn agent_has_managed_marker(
|
||||
|
||||
pub(crate) fn is_managed_agent_base_url(value: &str) -> bool {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
value.starts_with("http://127.0.0.1:") || value.starts_with("http://localhost:")
|
||||
if value.starts_with("http://127.0.0.1:")
|
||||
|| value.starts_with("http://localhost:")
|
||||
|| value.starts_with("https://127.0.0.1:")
|
||||
|| value.starts_with("https://localhost:")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let Ok(settings) = read_installed_core_config_settings() else {
|
||||
return false;
|
||||
};
|
||||
let host = core_connect_host(&settings.host);
|
||||
let host = if host.contains(':') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host
|
||||
};
|
||||
value.starts_with(&format!("http://{host}:"))
|
||||
|| value.starts_with(&format!("https://{host}:"))
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn inspect_managed_toml_model_marker(
|
||||
@@ -2767,7 +2791,7 @@ pub(crate) fn inspect_claude_agent_config(
|
||||
)
|
||||
.map_err(|error| format!("解析 Claude Code 配置失败: {error}"))?;
|
||||
let env = root.get("env").and_then(serde_json::Value::as_object);
|
||||
let expected_base = format!("http://127.0.0.1:{port}");
|
||||
let expected_base = managed_core_loopback_origin(port);
|
||||
let configured = env
|
||||
.and_then(|env| env.get("ANTHROPIC_BASE_URL"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
@@ -3100,7 +3124,7 @@ pub(crate) fn inspect_claude_desktop_agent_config(
|
||||
let threep = read_agent_json_or_empty(&paths[1], "Claude Desktop 3P 配置")?;
|
||||
let profile = read_agent_json_or_empty(&paths[2], "Claude Desktop 网关配置")?;
|
||||
let meta = read_agent_json_or_empty(&paths[3], "Claude Desktop 配置索引")?;
|
||||
let expected_base = format!("http://127.0.0.1:{port}");
|
||||
let expected_base = managed_core_loopback_origin(port);
|
||||
let configured = normal
|
||||
.get("deploymentMode")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
@@ -3191,7 +3215,7 @@ pub(crate) fn inspect_codex_agent_config(
|
||||
&fs::read_to_string(path).map_err(|error| format!("读取 Codex 配置失败: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("解析 Codex 配置失败: {error}"))?;
|
||||
let expected_base = format!("http://127.0.0.1:{port}/v1");
|
||||
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
|
||||
let provider = root
|
||||
.get("model_providers")
|
||||
.and_then(toml::Value::as_table)
|
||||
@@ -3264,7 +3288,7 @@ pub(crate) fn inspect_opencode_agent_config(
|
||||
let provider = root
|
||||
.get("provider")
|
||||
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
|
||||
let expected_base = format!("http://127.0.0.1:{port}/v1");
|
||||
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
|
||||
let configured = provider
|
||||
.and_then(|provider| provider.get("options"))
|
||||
.and_then(|options| options.get("baseURL"))
|
||||
@@ -3301,7 +3325,7 @@ pub(crate) fn inspect_zcode_agent_config(
|
||||
let provider = root
|
||||
.get("provider")
|
||||
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
|
||||
let expected_base = format!("http://127.0.0.1:{port}");
|
||||
let expected_base = managed_core_loopback_origin(port);
|
||||
let api_format_matches = provider
|
||||
.and_then(|provider| provider.get("apiFormat"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
@@ -3363,7 +3387,7 @@ pub(crate) fn inspect_kimi_code_agent_config(
|
||||
&fs::read_to_string(path).map_err(|error| format!("读取 Kimi Code 配置失败: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("解析 Kimi Code 配置失败: {error}"))?;
|
||||
let expected_base = format!("http://127.0.0.1:{port}/v1");
|
||||
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
|
||||
let provider = root
|
||||
.get("providers")
|
||||
.and_then(toml::Value::as_table)
|
||||
@@ -3420,7 +3444,7 @@ pub(crate) fn inspect_grok_build_agent_config(
|
||||
&fs::read_to_string(path).map_err(|error| format!("读取 Grok Build 配置失败: {error}"))?,
|
||||
)
|
||||
.map_err(|error| format!("解析 Grok Build 配置失败: {error}"))?;
|
||||
let expected_base = format!("http://127.0.0.1:{port}/v1");
|
||||
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
|
||||
let prefix = format!("{MANAGED_AGENT_PROVIDER_ID}/");
|
||||
let selected = root
|
||||
.get("models")
|
||||
@@ -3474,7 +3498,7 @@ pub(crate) fn inspect_openclaw_agent_config(
|
||||
.get("models")
|
||||
.and_then(|models| models.get("providers"))
|
||||
.and_then(|providers| providers.get(MANAGED_AGENT_PROVIDER_ID));
|
||||
let expected_base = format!("http://127.0.0.1:{port}/v1");
|
||||
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
|
||||
let configured = provider
|
||||
.and_then(|provider| provider.get("baseUrl"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
@@ -3518,7 +3542,7 @@ pub(crate) fn inspect_hermes_agent_config(
|
||||
== Some(MANAGED_AGENT_PROVIDER_ID)
|
||||
})
|
||||
});
|
||||
let expected_base = format!("http://127.0.0.1:{port}/v1");
|
||||
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
|
||||
let configured = provider
|
||||
.and_then(|provider| provider.get("base_url"))
|
||||
.and_then(serde_yaml::Value::as_str)
|
||||
@@ -3574,7 +3598,7 @@ pub(crate) fn inspect_deepseek_harness_config(
|
||||
.map(str::to_string)
|
||||
})
|
||||
.flatten();
|
||||
let expected_base = format!("http://127.0.0.1:{port}/v1");
|
||||
let expected_base = format!("{}/v1", managed_core_loopback_origin(port));
|
||||
let credential = yaml_mapping_value(&credentials, DEEPSEEK_HARNESS_CREDENTIAL)
|
||||
.and_then(serde_norway::Value::as_str);
|
||||
let model_is_published = model.as_deref().is_some_and(|selected| {
|
||||
|
||||
@@ -689,7 +689,7 @@ pub(crate) fn fresh_agent_contents_with_oauth(
|
||||
claude_desktop_model_mappings,
|
||||
..
|
||||
} = options;
|
||||
let root_base = format!("http://127.0.0.1:{port}");
|
||||
let root_base = managed_core_loopback_origin(port);
|
||||
let openai_base = format!("{root_base}/v1");
|
||||
match client {
|
||||
AgentClient::ClaudeCode => Ok(vec![build_claude_agent_config(
|
||||
|
||||
@@ -5,6 +5,22 @@ pub(crate) fn get_lan_ipv4() -> Option<String> {
|
||||
detect_lan_ipv4().map(|address| address.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_core_tls_settings() -> Result<CoreTlsSettings, String> {
|
||||
current_core_tls_settings()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_core_tls_settings(
|
||||
cache: tauri::State<'_, AgentConfigStatusCache>,
|
||||
settings: CoreTlsSettings,
|
||||
) -> Result<CoreTlsSettings, String> {
|
||||
let settings = normalize_core_tls_settings(settings)?;
|
||||
patch_core_tls_settings(&settings)?;
|
||||
cache.clear()?;
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
pub(crate) fn detect_lan_ipv4() -> Option<Ipv4Addr> {
|
||||
for target in ["192.0.2.1:80", "8.8.8.8:80"] {
|
||||
let Ok(socket) = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)) else {
|
||||
@@ -127,6 +143,89 @@ pub(crate) fn save_network_routing_settings(
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_network_endpoint_settings(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
cache: tauri::State<'_, AgentConfigStatusCache>,
|
||||
settings: GuiNetworkEndpointSettings,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
if settings.port == 0 {
|
||||
return Err("Port must be between 1 and 65535".to_string());
|
||||
}
|
||||
let host = normalize_optional_config_string(settings.host, "Listen IP")?;
|
||||
if host.parse::<IpAddr>().is_err() {
|
||||
return Err("Listen IP must be a valid IPv4 or IPv6 address".to_string());
|
||||
}
|
||||
let proxy_url = normalize_optional_config_string(settings.proxy_url, "Proxy URL")?;
|
||||
let previous = gui_config_state.snapshot()?;
|
||||
let mut next = previous.clone();
|
||||
next.host = host;
|
||||
next.allow_lan = !is_loopback_host(&next.host);
|
||||
next.port = settings.port;
|
||||
next.proxy_url = proxy_url;
|
||||
validate_gui_config(&next)?;
|
||||
patch_core_network_endpoint_settings(&next)?;
|
||||
let config = match gui_config_state.update_network_endpoint(&next) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_network_endpoint_settings(&previous).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
if config.host != previous.host || config.port != previous.port {
|
||||
if let Err(error) = cache.clear() {
|
||||
eprintln!("Failed to clear agent configuration status cache: {error}");
|
||||
}
|
||||
}
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_retry_settings(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
settings: GuiRetrySettings,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let previous = gui_config_state.snapshot()?;
|
||||
let mut next = previous.clone();
|
||||
next.request_retry = settings.request_retry;
|
||||
next.max_retry_credentials = settings.max_retry_credentials;
|
||||
next.max_retry_interval = settings.max_retry_interval;
|
||||
next.streaming_bootstrap_retries = settings.streaming_bootstrap_retries;
|
||||
patch_core_retry_settings(&next)?;
|
||||
let config = match gui_config_state.update_retry_settings(&next) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_retry_settings(&previous).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn save_session_routing_settings(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
settings: GuiSessionRoutingSettings,
|
||||
) -> Result<CoreConfigView, String> {
|
||||
let ttl = normalize_optional_config_string(
|
||||
settings.routing_session_affinity_ttl,
|
||||
"Session affinity TTL",
|
||||
)?;
|
||||
let previous = gui_config_state.snapshot()?;
|
||||
let mut next = previous.clone();
|
||||
next.routing_session_affinity = settings.routing_session_affinity;
|
||||
next.routing_session_affinity_ttl = ttl;
|
||||
patch_core_session_routing_settings(&next)?;
|
||||
let config = match gui_config_state.update_session_routing(&next) {
|
||||
Ok(config) => config,
|
||||
Err(error) => {
|
||||
let rollback_error = patch_core_session_routing_settings(&previous).err();
|
||||
return Err(config_update_error_with_rollback(error, rollback_error));
|
||||
}
|
||||
};
|
||||
Ok(CoreConfigView::from(&config))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn get_core_config_settings(
|
||||
gui_config_state: tauri::State<'_, GuiConfigState>,
|
||||
|
||||
@@ -163,6 +163,80 @@ pub(crate) fn normalize_optional_config_string(
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_core_tls_settings(
|
||||
settings: CoreTlsSettings,
|
||||
) -> Result<CoreTlsSettings, String> {
|
||||
let cert = normalize_optional_config_string(settings.cert, "TLS certificate path")?;
|
||||
let key = normalize_optional_config_string(settings.key, "TLS private key path")?;
|
||||
if settings.enabled && (cert.is_empty() || key.is_empty()) {
|
||||
return Err(
|
||||
"TLS is enabled, so both the certificate and private key paths are required"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(CoreTlsSettings {
|
||||
enabled: settings.enabled,
|
||||
cert,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn core_loopback_origin(port: u16, tls_enabled: bool) -> String {
|
||||
core_origin("127.0.0.1", port, tls_enabled)
|
||||
}
|
||||
|
||||
pub(crate) fn core_connect_host(listen_host: &str) -> String {
|
||||
let host = listen_host
|
||||
.trim()
|
||||
.strip_prefix('[')
|
||||
.and_then(|value| value.strip_suffix(']'))
|
||||
.unwrap_or_else(|| listen_host.trim());
|
||||
match host {
|
||||
"" | "0.0.0.0" => "127.0.0.1".to_string(),
|
||||
"::" => "::1".to_string(),
|
||||
_ => host.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn core_origin(listen_host: &str, port: u16, tls_enabled: bool) -> String {
|
||||
let scheme = if tls_enabled { "https" } else { "http" };
|
||||
let host = core_connect_host(listen_host);
|
||||
let url_host = if host.contains(':') {
|
||||
format!("[{host}]")
|
||||
} else {
|
||||
host
|
||||
};
|
||||
format!("{scheme}://{url_host}:{port}")
|
||||
}
|
||||
|
||||
pub(crate) fn managed_core_tls_enabled() -> bool {
|
||||
#[cfg(test)]
|
||||
{
|
||||
false
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
current_core_tls_settings()
|
||||
.map(|settings| settings.enabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn managed_core_loopback_origin(port: u16) -> String {
|
||||
#[cfg(test)]
|
||||
{
|
||||
core_loopback_origin(port, false)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
let host = read_installed_core_config_settings()
|
||||
.map(|settings| settings.host)
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
core_origin(&host, port, managed_core_tls_enabled())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn config_update_error_with_rollback(
|
||||
error: String,
|
||||
rollback_error: Option<String>,
|
||||
@@ -370,6 +444,76 @@ pub(crate) fn patch_core_network_routing_yaml(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_network_endpoint_yaml(
|
||||
content: &str,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<Option<String>, String> {
|
||||
patch_core_yaml_document(content, |document| {
|
||||
let original = document.clone();
|
||||
apply_network_settings(document, config)?;
|
||||
set_core_yaml_top_level_value(
|
||||
document,
|
||||
"proxy-url",
|
||||
serde_norway::Value::String(config.proxy_url.clone()),
|
||||
)?;
|
||||
Ok(*document != original)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_retry_yaml(
|
||||
content: &str,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<Option<String>, String> {
|
||||
patch_core_yaml_document(content, |document| {
|
||||
let original = document.clone();
|
||||
set_core_yaml_top_level_value(
|
||||
document,
|
||||
"request-retry",
|
||||
serde_norway::to_value(config.request_retry).map_err(|err| err.to_string())?,
|
||||
)?;
|
||||
set_core_yaml_top_level_value(
|
||||
document,
|
||||
"max-retry-credentials",
|
||||
serde_norway::to_value(config.max_retry_credentials).map_err(|err| err.to_string())?,
|
||||
)?;
|
||||
set_core_yaml_top_level_value(
|
||||
document,
|
||||
"max-retry-interval",
|
||||
serde_norway::to_value(config.max_retry_interval).map_err(|err| err.to_string())?,
|
||||
)?;
|
||||
set_core_yaml_nested_value(
|
||||
document,
|
||||
"streaming",
|
||||
"bootstrap-retries",
|
||||
serde_norway::to_value(config.streaming_bootstrap_retries)
|
||||
.map_err(|err| err.to_string())?,
|
||||
)?;
|
||||
Ok(*document != original)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_session_routing_yaml(
|
||||
content: &str,
|
||||
config: &GuiConfigFile,
|
||||
) -> Result<Option<String>, String> {
|
||||
patch_core_yaml_document(content, |document| {
|
||||
let original = document.clone();
|
||||
set_core_yaml_nested_value(
|
||||
document,
|
||||
"routing",
|
||||
"session-affinity",
|
||||
serde_norway::Value::Bool(config.routing_session_affinity),
|
||||
)?;
|
||||
set_core_yaml_nested_value(
|
||||
document,
|
||||
"routing",
|
||||
"session-affinity-ttl",
|
||||
serde_norway::Value::String(config.routing_session_affinity_ttl.clone()),
|
||||
)?;
|
||||
Ok(*document != original)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn merge_yaml_values(base: &mut serde_norway::Value, current: serde_norway::Value) {
|
||||
match (base, current) {
|
||||
(
|
||||
@@ -1316,7 +1460,13 @@ pub(crate) fn persist_main_window_size(app: &tauri::AppHandle) -> Result<(), Str
|
||||
}
|
||||
|
||||
pub(crate) fn is_loopback_host(host: &str) -> bool {
|
||||
matches!(host.trim(), "127.0.0.1" | "localhost" | "::1")
|
||||
let host = host.trim();
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.parse::<IpAddr>()
|
||||
.is_ok_and(|address| address.is_loopback())
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, String> {
|
||||
|
||||
@@ -67,6 +67,38 @@ pub(crate) fn patch_core_network_routing_settings(config: &GuiConfigFile) -> Res
|
||||
write_yaml_if_changed(&config_path, &updated).map(|_| ())
|
||||
}
|
||||
|
||||
fn patch_installed_core_config_with(
|
||||
patch: impl FnOnce(&str) -> Result<Option<String>, String>,
|
||||
) -> Result<(), String> {
|
||||
let _config_guard = lock_core_config_file()?;
|
||||
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
|
||||
if !config_path.is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let content = fs::read_to_string(&config_path).map_err(|err| {
|
||||
format!(
|
||||
"Failed to read core configuration {}: {err}",
|
||||
path_to_string(&config_path)
|
||||
)
|
||||
})?;
|
||||
let Some(updated) = patch(&content)? else {
|
||||
return Ok(());
|
||||
};
|
||||
write_yaml_if_changed(&config_path, &updated).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_network_endpoint_settings(config: &GuiConfigFile) -> Result<(), String> {
|
||||
patch_installed_core_config_with(|content| patch_core_network_endpoint_yaml(content, config))
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_retry_settings(config: &GuiConfigFile) -> Result<(), String> {
|
||||
patch_installed_core_config_with(|content| patch_core_retry_yaml(content, config))
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_session_routing_settings(config: &GuiConfigFile) -> Result<(), String> {
|
||||
patch_installed_core_config_with(|content| patch_core_session_routing_yaml(content, config))
|
||||
}
|
||||
|
||||
pub(crate) fn lock_core_config_file() -> Result<std::sync::MutexGuard<'static, ()>, String> {
|
||||
CORE_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
@@ -91,6 +123,107 @@ pub(crate) fn current_core_config_settings(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn current_core_tls_settings() -> Result<CoreTlsSettings, String> {
|
||||
let _config_guard = lock_core_config_file()?;
|
||||
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
|
||||
if !config_path.is_file() {
|
||||
return Ok(CoreTlsSettings {
|
||||
enabled: false,
|
||||
cert: String::new(),
|
||||
key: String::new(),
|
||||
});
|
||||
}
|
||||
let content = fs::read_to_string(&config_path).map_err(|err| {
|
||||
format!(
|
||||
"Failed to read core configuration {}: {err}",
|
||||
path_to_string(&config_path)
|
||||
)
|
||||
})?;
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&content)
|
||||
.map_err(|err| format!("Failed to parse core configuration: {err}"))?;
|
||||
core_tls_settings_from_value(&document)
|
||||
}
|
||||
|
||||
pub(crate) fn core_tls_settings_from_value(
|
||||
document: &serde_norway::Value,
|
||||
) -> Result<CoreTlsSettings, String> {
|
||||
let root = document
|
||||
.as_mapping()
|
||||
.ok_or_else(|| "Core configuration must be a YAML mapping".to_string())?;
|
||||
let tls = yaml_mapping_value(root, "tls").and_then(serde_norway::Value::as_mapping);
|
||||
let enabled = tls
|
||||
.and_then(|mapping| yaml_mapping_value(mapping, "enable"))
|
||||
.map(|value| {
|
||||
value
|
||||
.as_bool()
|
||||
.ok_or_else(|| "tls.enable must be a boolean".to_string())
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(false);
|
||||
let string_value = |key: &str| -> Result<String, String> {
|
||||
tls.and_then(|mapping| yaml_mapping_value(mapping, key))
|
||||
.map(|value| {
|
||||
value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| format!("tls.{key} must be a string"))
|
||||
})
|
||||
.transpose()
|
||||
.map(Option::unwrap_or_default)
|
||||
};
|
||||
Ok(CoreTlsSettings {
|
||||
enabled,
|
||||
cert: string_value("cert")?,
|
||||
key: string_value("key")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_tls_settings_yaml(
|
||||
content: &str,
|
||||
settings: &CoreTlsSettings,
|
||||
) -> Result<Option<String>, String> {
|
||||
patch_core_yaml_document(content, |document| {
|
||||
let mut changed = false;
|
||||
changed |= set_core_yaml_nested_value(
|
||||
document,
|
||||
"tls",
|
||||
"enable",
|
||||
serde_norway::Value::Bool(settings.enabled),
|
||||
)?;
|
||||
changed |= set_core_yaml_nested_value(
|
||||
document,
|
||||
"tls",
|
||||
"cert",
|
||||
serde_norway::Value::String(settings.cert.clone()),
|
||||
)?;
|
||||
changed |= set_core_yaml_nested_value(
|
||||
document,
|
||||
"tls",
|
||||
"key",
|
||||
serde_norway::Value::String(settings.key.clone()),
|
||||
)?;
|
||||
Ok(changed)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_tls_settings(settings: &CoreTlsSettings) -> Result<(), String> {
|
||||
let _config_guard = lock_core_config_file()?;
|
||||
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
|
||||
if !config_path.is_file() {
|
||||
return Err("Core configuration has not been generated yet".to_string());
|
||||
}
|
||||
let content = fs::read_to_string(&config_path).map_err(|err| {
|
||||
format!(
|
||||
"Failed to read core configuration {}: {err}",
|
||||
path_to_string(&config_path)
|
||||
)
|
||||
})?;
|
||||
let Some(updated) = patch_core_tls_settings_yaml(&content, settings)? else {
|
||||
return Ok(());
|
||||
};
|
||||
write_yaml_if_changed(&config_path, &updated).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn patch_core_api_keys(api_keys: &[String]) -> Result<(), String> {
|
||||
let _config_guard = lock_core_config_file()?;
|
||||
let config_path = core_install_dir()?.join(CORE_CONFIG_FILE);
|
||||
|
||||
@@ -837,10 +837,23 @@ pub(crate) fn current_core_status(
|
||||
}
|
||||
|
||||
pub(crate) fn is_management_port_open(port: u16) -> bool {
|
||||
let address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
|
||||
let listen_host = read_installed_core_config_settings()
|
||||
.map(|settings| settings.host)
|
||||
.unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||
let Ok(address) = core_management_address(&listen_host, port) else {
|
||||
return false;
|
||||
};
|
||||
TcpStream::connect_timeout(&address, Duration::from_millis(150)).is_ok()
|
||||
}
|
||||
|
||||
fn core_management_address(listen_host: &str, port: u16) -> Result<SocketAddr, String> {
|
||||
let host = core_connect_host(listen_host);
|
||||
let ip = host
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| format!("Invalid core listen IP: {listen_host}"))?;
|
||||
Ok(SocketAddr::new(ip, port))
|
||||
}
|
||||
|
||||
pub(crate) fn start_core_process_inner(
|
||||
process_state: &CoreProcessState,
|
||||
gui_config: &GuiConfigFile,
|
||||
@@ -857,7 +870,7 @@ pub(crate) fn start_core_process_inner(
|
||||
if process_state.managed_pid().is_some() || is_core_running(&binary_path) {
|
||||
return Err("CPA 内核已经在运行".to_string());
|
||||
}
|
||||
let management_address = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), gui_config.port);
|
||||
let management_address = core_management_address(&gui_config.host, gui_config.port)?;
|
||||
if TcpStream::connect_timeout(&management_address, Duration::from_millis(250)).is_ok() {
|
||||
return Err(format!(
|
||||
"端口 {} 已被其他程序占用,请更换端口后重试",
|
||||
|
||||
@@ -733,6 +733,7 @@ struct GuiConfigPresence {
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GuiSettings {
|
||||
host: String,
|
||||
port: u16,
|
||||
allow_lan: bool,
|
||||
run_on_startup: bool,
|
||||
@@ -1120,6 +1121,38 @@ struct GuiNetworkRoutingSettings {
|
||||
streaming_bootstrap_retries: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GuiNetworkEndpointSettings {
|
||||
host: String,
|
||||
port: u16,
|
||||
proxy_url: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GuiRetrySettings {
|
||||
request_retry: u32,
|
||||
max_retry_credentials: u32,
|
||||
max_retry_interval: u32,
|
||||
streaming_bootstrap_retries: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GuiSessionRoutingSettings {
|
||||
routing_session_affinity: bool,
|
||||
routing_session_affinity_ttl: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CoreTlsSettings {
|
||||
enabled: bool,
|
||||
cert: String,
|
||||
key: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CoreConfigSettings {
|
||||
@@ -1158,6 +1191,7 @@ struct CoreApiKeyView {
|
||||
#[derive(Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CoreConfigView {
|
||||
host: String,
|
||||
api_keys: Vec<CoreApiKeyView>,
|
||||
management_secret_configured: bool,
|
||||
port: u16,
|
||||
@@ -1556,6 +1590,34 @@ impl GuiConfigState {
|
||||
})
|
||||
}
|
||||
|
||||
fn update_network_endpoint(&self, settings: &GuiConfigFile) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.host = settings.host.clone();
|
||||
config.allow_lan = settings.allow_lan;
|
||||
config.port = settings.port;
|
||||
config.proxy_url = settings.proxy_url.clone();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn update_retry_settings(&self, settings: &GuiConfigFile) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.request_retry = settings.request_retry;
|
||||
config.max_retry_credentials = settings.max_retry_credentials;
|
||||
config.max_retry_interval = settings.max_retry_interval;
|
||||
config.streaming_bootstrap_retries = settings.streaming_bootstrap_retries;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn update_session_routing(&self, settings: &GuiConfigFile) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.routing_session_affinity = settings.routing_session_affinity;
|
||||
config.routing_session_affinity_ttl = settings.routing_session_affinity_ttl.clone();
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_run_on_startup(&self, run_on_startup: bool) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.run_on_startup = run_on_startup;
|
||||
@@ -1670,6 +1732,7 @@ impl GuiConfigState {
|
||||
impl From<&GuiConfigFile> for GuiSettings {
|
||||
fn from(config: &GuiConfigFile) -> Self {
|
||||
Self {
|
||||
host: config.host.clone(),
|
||||
port: config.port,
|
||||
allow_lan: config.allow_lan,
|
||||
run_on_startup: config.run_on_startup,
|
||||
@@ -1704,6 +1767,7 @@ impl From<&GuiConfigFile> for CoreConfigSettings {
|
||||
impl From<&GuiConfigFile> for CoreConfigView {
|
||||
fn from(config: &GuiConfigFile) -> Self {
|
||||
Self {
|
||||
host: config.host.clone(),
|
||||
api_keys: config
|
||||
.api_keys
|
||||
.iter()
|
||||
@@ -1994,6 +2058,11 @@ fn main() {
|
||||
get_lan_ipv4,
|
||||
save_gui_settings,
|
||||
save_network_routing_settings,
|
||||
save_network_endpoint_settings,
|
||||
save_retry_settings,
|
||||
save_session_routing_settings,
|
||||
get_core_tls_settings,
|
||||
save_core_tls_settings,
|
||||
get_core_config_settings,
|
||||
add_core_api_key,
|
||||
update_core_api_key,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::windows_explorer_executable;
|
||||
use super::{
|
||||
auth_dir_path_for_core, configure_background_command, core_install_dir,
|
||||
is_hashed_management_secret_key, open_oauth_url_inner, path_to_string, truncate_for_error,
|
||||
GuiConfigFile, GuiConfigState,
|
||||
auth_dir_path_for_core, configure_background_command, core_install_dir, core_origin,
|
||||
current_core_tls_settings, is_hashed_management_secret_key, open_oauth_url_inner,
|
||||
path_to_string, truncate_for_error, GuiConfigFile, GuiConfigState,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
@@ -284,6 +284,9 @@ pub(crate) fn management_http_client() -> Result<reqwest::Client, String> {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.timeout(Duration::from_secs(30))
|
||||
// Management requests target the configured local listener. This keeps
|
||||
// self-signed/private-CA certificates usable without weakening upstream clients.
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(|err| format!("创建管理 API 客户端失败: {err}"))
|
||||
}
|
||||
@@ -301,10 +304,12 @@ pub(crate) fn management_endpoint(config: &GuiConfigFile, path: &str) -> Result<
|
||||
return Err("内核端口无效".to_string());
|
||||
}
|
||||
let path = path.trim_start_matches('/');
|
||||
Ok(format!(
|
||||
"http://127.0.0.1:{}/v0/management/{path}",
|
||||
config.port
|
||||
))
|
||||
let origin = core_origin(
|
||||
&config.host,
|
||||
config.port,
|
||||
current_core_tls_settings()?.enabled,
|
||||
);
|
||||
Ok(format!("{origin}/v0/management/{path}"))
|
||||
}
|
||||
|
||||
fn normalize_management_oauth_provider(provider: &str) -> Result<String, String> {
|
||||
|
||||
@@ -507,6 +507,51 @@ fn confirmed_network_routing_patch_updates_all_fields_together() {
|
||||
assert!(updated.contains("# unrelated"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn independent_network_modules_only_patch_their_own_fields() {
|
||||
let config = GuiConfigFile {
|
||||
host: "192.168.1.20".to_string(),
|
||||
port: 9527,
|
||||
proxy_url: "socks5://127.0.0.1:7890".to_string(),
|
||||
routing_session_affinity: true,
|
||||
routing_session_affinity_ttl: "2h".to_string(),
|
||||
request_retry: 7,
|
||||
max_retry_credentials: 8,
|
||||
max_retry_interval: 9,
|
||||
streaming_bootstrap_retries: 10,
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
let input = "host: 127.0.0.1\nport: 8317\nproxy-url: \"\"\nrouting:\n session-affinity: false\n session-affinity-ttl: 1h\nrequest-retry: 1\nmax-retry-credentials: 2\nmax-retry-interval: 3\nstreaming:\n bootstrap-retries: 4\n";
|
||||
|
||||
let network = patch_core_network_endpoint_yaml(input, &config)
|
||||
.unwrap()
|
||||
.expect("network endpoint should change");
|
||||
let network = serde_norway::from_str::<serde_norway::Value>(&network).unwrap();
|
||||
assert_eq!(network["host"], "192.168.1.20");
|
||||
assert_eq!(network["port"], 9527);
|
||||
assert_eq!(network["proxy-url"], "socks5://127.0.0.1:7890");
|
||||
assert_eq!(network["request-retry"], 1);
|
||||
assert_eq!(network["routing"]["session-affinity"], false);
|
||||
|
||||
let retry = patch_core_retry_yaml(input, &config)
|
||||
.unwrap()
|
||||
.expect("retry settings should change");
|
||||
let retry = serde_norway::from_str::<serde_norway::Value>(&retry).unwrap();
|
||||
assert_eq!(retry["request-retry"], 7);
|
||||
assert_eq!(retry["max-retry-credentials"], 8);
|
||||
assert_eq!(retry["max-retry-interval"], 9);
|
||||
assert_eq!(retry["streaming"]["bootstrap-retries"], 10);
|
||||
assert_eq!(retry["host"], "127.0.0.1");
|
||||
|
||||
let routing = patch_core_session_routing_yaml(input, &config)
|
||||
.unwrap()
|
||||
.expect("session routing should change");
|
||||
let routing = serde_norway::from_str::<serde_norway::Value>(&routing).unwrap();
|
||||
assert_eq!(routing["routing"]["session-affinity"], true);
|
||||
assert_eq!(routing["routing"]["session-affinity-ttl"], "2h");
|
||||
assert_eq!(routing["request-retry"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_config_controls_preserve_comments_and_unrelated_values() {
|
||||
let input = "# Client authentication\napi-keys:\n - old-key\n\n# Plugin runtime\nplugins:\n enabled: false # global switch\n dir: plugins\n\n# Credential routing\nrouting:\n strategy: round-robin # current strategy\n session-affinity: true\n\ndebug: true # untouched\n";
|
||||
@@ -846,6 +891,86 @@ fn optional_core_strings_are_trimmed_and_reject_control_characters() {
|
||||
assert!(normalize_optional_config_string("bad\nvalue".to_string(), "代理 URL").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_tls_settings_read_defaults_and_configured_values() {
|
||||
let defaults = serde_norway::from_str::<serde_norway::Value>("host: 127.0.0.1\n").unwrap();
|
||||
let configured = serde_norway::from_str::<serde_norway::Value>(
|
||||
"tls:\n enable: true\n cert: C:/certs/server.crt\n key: C:/certs/server.key\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let defaults = core_tls_settings_from_value(&defaults).unwrap();
|
||||
assert!(!defaults.enabled);
|
||||
assert!(defaults.cert.is_empty());
|
||||
assert!(defaults.key.is_empty());
|
||||
|
||||
let configured = core_tls_settings_from_value(&configured).unwrap();
|
||||
assert!(configured.enabled);
|
||||
assert_eq!(configured.cert, "C:/certs/server.crt");
|
||||
assert_eq!(configured.key, "C:/certs/server.key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls_patch_preserves_unrelated_yaml_and_paths_when_disabled() {
|
||||
let input = "# server\nhost: 127.0.0.1\ntls:\n enable: true\n cert: old.crt\n key: old.key\ncustom:\n keep: true\n";
|
||||
let settings = CoreTlsSettings {
|
||||
enabled: false,
|
||||
cert: "new.crt".to_string(),
|
||||
key: "new.key".to_string(),
|
||||
};
|
||||
let patched = patch_core_tls_settings_yaml(input, &settings)
|
||||
.unwrap()
|
||||
.expect("TLS settings should change");
|
||||
let document = serde_norway::from_str::<serde_norway::Value>(&patched).unwrap();
|
||||
|
||||
assert!(patched.contains("# server"));
|
||||
assert_eq!(document["tls"]["enable"], false);
|
||||
assert_eq!(document["tls"]["cert"], "new.crt");
|
||||
assert_eq!(document["tls"]["key"], "new.key");
|
||||
assert_eq!(document["custom"]["keep"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_tls_requires_both_paths() {
|
||||
let missing_key = CoreTlsSettings {
|
||||
enabled: true,
|
||||
cert: "server.crt".to_string(),
|
||||
key: String::new(),
|
||||
};
|
||||
assert!(normalize_core_tls_settings(missing_key).is_err());
|
||||
|
||||
let disabled = CoreTlsSettings {
|
||||
enabled: false,
|
||||
cert: " server.crt ".to_string(),
|
||||
key: String::new(),
|
||||
};
|
||||
let disabled = normalize_core_tls_settings(disabled).unwrap();
|
||||
assert_eq!(disabled.cert, "server.crt");
|
||||
assert!(disabled.key.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_loopback_origin_uses_the_selected_transport() {
|
||||
assert_eq!(core_loopback_origin(8317, false), "http://127.0.0.1:8317");
|
||||
assert_eq!(core_loopback_origin(9527, true), "https://127.0.0.1:9527");
|
||||
assert!(is_managed_agent_base_url("https://127.0.0.1:9527/v1"));
|
||||
assert!(is_managed_agent_base_url("https://localhost:9527"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_origin_uses_connectable_custom_and_ipv6_hosts() {
|
||||
assert_eq!(
|
||||
core_origin("192.168.1.20", 9527, true),
|
||||
"https://192.168.1.20:9527"
|
||||
);
|
||||
assert_eq!(core_origin("0.0.0.0", 8317, false), "http://127.0.0.1:8317");
|
||||
assert_eq!(core_origin("::", 8317, false), "http://[::1]:8317");
|
||||
assert_eq!(
|
||||
core_origin("2001:db8::1", 8317, true),
|
||||
"https://[2001:db8::1]:8317"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_proxy_supports_http_and_socks5_urls() {
|
||||
for proxy_url in ["http://127.0.0.1:8080", "socks5://127.0.0.1:7890"] {
|
||||
|
||||
+29
-4
@@ -273,13 +273,15 @@ export const jaOverrides = {
|
||||
'config.routing.fillFirst': '優先充填',
|
||||
'config.tabs.label': '詳細設定ページ',
|
||||
'config.tabs.general': '基本設定',
|
||||
'config.tabs.network': 'ネットワークとルーティング',
|
||||
'config.tabs.network': 'ネットワーク設定',
|
||||
'config.tabs.routing': 'セッションルーティング',
|
||||
'config.tabs.software': 'ソフトウェア設定',
|
||||
'config.error.emptyKey': '認証キーを入力してください',
|
||||
'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます',
|
||||
'config.error.duplicateKey': '同じ認証キーがすでに存在します',
|
||||
'config.error.remarkTooLong': 'メモは 80 文字以内で入力してください',
|
||||
'config.error.portRange': 'ポートは 1〜65535 の整数で入力してください',
|
||||
'config.error.hostRequired': '有効なリッスン IP アドレスを入力してください',
|
||||
'config.error.retryRange': '再試行設定は 0〜4294967295 の整数で入力してください',
|
||||
'config.error.networkRestartFailed': '設定は保存されましたが、コアの再起動に失敗しました:{error}',
|
||||
'config.error.saveFailed': '保存に失敗しました:{error}',
|
||||
@@ -290,6 +292,8 @@ export const jaOverrides = {
|
||||
'config.notice.keyCopyFailed': '認証キーをコピーできませんでした',
|
||||
'config.notice.routingUpdated': 'ルーティング方式を更新しました',
|
||||
'config.notice.networkUpdated': 'ネットワークとセッションルーティング設定を保存しました',
|
||||
'config.notice.retryUpdated': '失敗時の再試行設定を保存しました',
|
||||
'config.notice.sessionRoutingUpdated': 'セッションルーティング設定を保存しました',
|
||||
'config.notice.networkRestarted': 'ネットワーク設定を保存し、コアを再起動しました',
|
||||
'config.notice.networkNextStart': 'ネットワーク設定を保存しました。次回のコア起動時に反映されます',
|
||||
'config.notice.proxyUpdated': 'プロキシ URL を保存しました',
|
||||
@@ -334,7 +338,7 @@ export const jaOverrides = {
|
||||
'common.enabled': '有効',
|
||||
'common.disabled': '無効',
|
||||
'config.routing.title': 'ルーティング方式',
|
||||
'config.network.title': 'ネットワークとセッションルーティング',
|
||||
'config.network.title': 'ネットワーク設定',
|
||||
'config.network.networkSection': 'ネットワーク設定',
|
||||
'config.network.routingSection': 'セッションルーティング',
|
||||
'config.network.retrySection': '失敗時の再試行',
|
||||
@@ -344,8 +348,8 @@ export const jaOverrides = {
|
||||
'config.network.confirmSave': '確認して保存',
|
||||
'config.network.port': 'ポート',
|
||||
'config.network.portHint': '範囲は 1〜65535。コア実行中の変更時は自動的に再起動します',
|
||||
'config.network.allowLan': 'LAN アクセスを許可',
|
||||
'config.network.allowLanHint': 'LAN 内の他のデバイスからこのプロキシへのアクセスを許可します',
|
||||
'config.network.listenHost': 'カスタムリッスン IP',
|
||||
'config.network.listenHostHint': '例: 127.0.0.1、0.0.0.0、または LAN IP。変更すると実行中のコアが再起動します',
|
||||
'config.network.proxyUrl': 'プロキシ URL',
|
||||
'config.network.proxyPlaceholder': '例:socks5://127.0.0.1:7890',
|
||||
'config.network.proxyHint': 'SOCKS プロトコルと HTTP プロトコルに対応しています',
|
||||
@@ -362,6 +366,24 @@ export const jaOverrides = {
|
||||
'config.network.streamingBootstrapRetries': 'ストリーミング開始時の再試行回数',
|
||||
'config.network.streamingBootstrapRetriesHint': '最初の応答を送信する前に許可する安全な再試行回数です。0 で無効になります',
|
||||
'config.notice.softwareUpdated': 'ソフトウェア設定を保存しました',
|
||||
'config.tls.enable': 'TLS を有効にする',
|
||||
'config.tls.enableDescription': '有効な PEM 証明書と秘密鍵を使用して API と WebUI を HTTPS で提供します',
|
||||
'config.tls.cert': '証明書のパス',
|
||||
'config.tls.certPlaceholder': '例: C:\\certs\\server.crt または /path/to/cert.pem',
|
||||
'config.tls.certHint': 'コアから読み取り可能な PEM 証明書ファイルのパス',
|
||||
'config.tls.key': '秘密鍵のパス',
|
||||
'config.tls.keyPlaceholder': '例: C:\\certs\\server.key または /path/to/key.pem',
|
||||
'config.tls.keyHint': '証明書と一致する PEM 秘密鍵ファイルのパス',
|
||||
'config.tls.browse': 'ファイルを選択',
|
||||
'config.tls.selectCertTitle': 'TLS 証明書ファイルを選択',
|
||||
'config.tls.selectKeyTitle': 'TLS 秘密鍵ファイルを選択',
|
||||
'config.tls.certFile': 'TLS 証明書',
|
||||
'config.tls.keyFile': 'TLS 秘密鍵',
|
||||
'config.tls.error.selectFileFailed': 'ファイルを選択できませんでした: {error}',
|
||||
'config.tls.restartHint': 'TLS 設定を保存すると、実行中のコアは自動的に再起動します。',
|
||||
'config.tls.error.pathsRequired': 'TLS を有効にする前に証明書と秘密鍵のパスを入力してください',
|
||||
'config.tls.notice.saved': 'TLS 設定を保存しました。次回のコア起動時に反映されます',
|
||||
'config.tls.notice.savedAndRestarted': 'TLS 設定を保存し、コアを再起動しました',
|
||||
'config.software.title': 'ソフトウェア設定',
|
||||
'config.software.autostart': 'システム起動時に開始',
|
||||
'config.software.autostartDescription': 'サインイン後に EasyCLIProxyAPI を自動的に起動します',
|
||||
@@ -928,6 +950,9 @@ export const jaOverrides = {
|
||||
'kernel.access.localUrl': 'ローカル URL',
|
||||
'kernel.access.localCopied': '{name} のローカル URL をコピーしました',
|
||||
'kernel.access.copyLocal': '{name} のローカル URL をコピー',
|
||||
'kernel.access.apiUrl': 'API URL',
|
||||
'kernel.access.apiCopied': '{name} の API URL をコピーしました',
|
||||
'kernel.access.copyApi': '{name} の API URL をコピー',
|
||||
'kernel.access.lanUrl': 'LAN URL',
|
||||
'kernel.access.detectingIp': 'LAN IP を検出しています',
|
||||
'kernel.access.noIp': 'LAN IP を検出できません',
|
||||
|
||||
+29
-4
@@ -273,13 +273,15 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.routing.fillFirst': 'Fill First',
|
||||
'config.tabs.label': 'Advanced settings sections',
|
||||
'config.tabs.general': 'General Settings',
|
||||
'config.tabs.network': 'Network & Routing',
|
||||
'config.tabs.network': 'Network Settings',
|
||||
'config.tabs.routing': 'Session Routing',
|
||||
'config.tabs.software': 'Software Settings',
|
||||
'config.error.emptyKey': 'The authentication key cannot be empty',
|
||||
'config.error.invalidKey': 'Use visible ASCII characters only, without spaces',
|
||||
'config.error.duplicateKey': 'This authentication key already exists',
|
||||
'config.error.remarkTooLong': 'The remark cannot exceed 80 characters',
|
||||
'config.error.portRange': 'The port must be an integer from 1 to 65535',
|
||||
'config.error.hostRequired': 'Enter a valid listen IP address',
|
||||
'config.error.retryRange': 'Retry settings must be integers from 0 to 4294967295',
|
||||
'config.error.networkRestartFailed': 'Settings were saved, but the core failed to restart: {error}',
|
||||
'config.error.saveFailed': 'Failed to save: {error}',
|
||||
@@ -290,6 +292,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.notice.keyCopyFailed': 'Failed to copy the authentication key',
|
||||
'config.notice.routingUpdated': 'Routing strategy updated',
|
||||
'config.notice.networkUpdated': 'Network and session routing settings saved',
|
||||
'config.notice.retryUpdated': 'Failure retry settings saved',
|
||||
'config.notice.sessionRoutingUpdated': 'Session routing settings saved',
|
||||
'config.notice.networkRestarted': 'Network settings saved and the core restarted',
|
||||
'config.notice.networkNextStart': 'Network settings saved and will apply on the next core start',
|
||||
'config.notice.proxyUpdated': 'Proxy URL saved',
|
||||
@@ -297,6 +301,24 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.notice.sessionAffinityDisabled': 'Session affinity disabled',
|
||||
'config.notice.sessionTtlUpdated': 'Session affinity TTL saved',
|
||||
'config.notice.softwareUpdated': 'Software settings saved',
|
||||
'config.tls.enable': 'Enable TLS',
|
||||
'config.tls.enableDescription': 'Serve the API and WebUI over HTTPS using a valid PEM certificate and private key',
|
||||
'config.tls.cert': 'Certificate Path',
|
||||
'config.tls.certPlaceholder': 'For example, C:\\certs\\server.crt or /path/to/cert.pem',
|
||||
'config.tls.certHint': 'Path to a PEM certificate file readable by the core',
|
||||
'config.tls.key': 'Private Key Path',
|
||||
'config.tls.keyPlaceholder': 'For example, C:\\certs\\server.key or /path/to/key.pem',
|
||||
'config.tls.keyHint': 'Path to the matching PEM private key file',
|
||||
'config.tls.browse': 'Choose File',
|
||||
'config.tls.selectCertTitle': 'Select TLS certificate file',
|
||||
'config.tls.selectKeyTitle': 'Select TLS private key file',
|
||||
'config.tls.certFile': 'TLS Certificate',
|
||||
'config.tls.keyFile': 'TLS Private Key',
|
||||
'config.tls.error.selectFileFailed': 'Failed to select file: {error}',
|
||||
'config.tls.restartHint': 'Saving TLS settings automatically restarts a running core.',
|
||||
'config.tls.error.pathsRequired': 'Certificate and private key paths are required before enabling TLS',
|
||||
'config.tls.notice.saved': 'TLS settings saved and will apply on the next core start',
|
||||
'config.tls.notice.savedAndRestarted': 'TLS settings saved and the core restarted',
|
||||
'config.software.title': 'Software Settings',
|
||||
'config.software.autostart': 'Launch at startup',
|
||||
'config.software.autostartDescription': 'Automatically launch EasyCLIProxyAPI when you sign in',
|
||||
@@ -347,7 +369,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'common.enabled': 'Enabled',
|
||||
'common.disabled': 'Disabled',
|
||||
'config.routing.title': 'Routing Strategy',
|
||||
'config.network.title': 'Network & Session Routing',
|
||||
'config.network.title': 'Network Settings',
|
||||
'config.network.networkSection': 'Network Settings',
|
||||
'config.network.routingSection': 'Session Routing',
|
||||
'config.network.retrySection': 'Failure Retries',
|
||||
@@ -357,8 +379,8 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.network.confirmSave': 'Confirm & Save',
|
||||
'config.network.port': 'Port',
|
||||
'config.network.portHint': 'Range 1–65535; changing it restarts a running core',
|
||||
'config.network.allowLan': 'Allow LAN',
|
||||
'config.network.allowLanHint': 'Allow other devices on the LAN to access this proxy',
|
||||
'config.network.listenHost': 'Custom Listen IP',
|
||||
'config.network.listenHostHint': 'For example, 127.0.0.1, 0.0.0.0, or a LAN IP; changing it restarts a running core',
|
||||
'config.network.proxyUrl': 'Proxy URL',
|
||||
'config.network.proxyPlaceholder': 'For example, socks5://127.0.0.1:7890',
|
||||
'config.network.proxyHint': 'Supports SOCKS and HTTP protocols',
|
||||
@@ -928,6 +950,9 @@ export const en: Record<MessageKey, string> = {
|
||||
'kernel.access.localUrl': 'Local URL',
|
||||
'kernel.access.localCopied': '{name} local URL copied',
|
||||
'kernel.access.copyLocal': 'Copy {name} local URL',
|
||||
'kernel.access.apiUrl': 'API URL',
|
||||
'kernel.access.apiCopied': '{name} API URL copied',
|
||||
'kernel.access.copyApi': 'Copy {name} API URL',
|
||||
'kernel.access.lanUrl': 'LAN URL',
|
||||
'kernel.access.detectingIp': 'Detecting LAN IP',
|
||||
'kernel.access.noIp': 'No LAN IP detected',
|
||||
|
||||
@@ -271,13 +271,15 @@ export const zhCN = {
|
||||
'config.routing.fillFirst': '优先填充',
|
||||
'config.tabs.label': '高级设置子页面',
|
||||
'config.tabs.general': '基础设置',
|
||||
'config.tabs.network': '网络与路由',
|
||||
'config.tabs.network': '网络设置',
|
||||
'config.tabs.routing': '会话路由',
|
||||
'config.tabs.software': '软件设置',
|
||||
'config.error.emptyKey': '鉴权密钥不能为空',
|
||||
'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格',
|
||||
'config.error.duplicateKey': '该鉴权密钥已经存在',
|
||||
'config.error.remarkTooLong': '备注不能超过 80 个字符',
|
||||
'config.error.portRange': '端口必须是 1–65535 之间的整数',
|
||||
'config.error.hostRequired': '请输入有效的监听 IP 地址',
|
||||
'config.error.retryRange': '重试设置必须是 0–4294967295 之间的整数',
|
||||
'config.error.networkRestartFailed': '设置已保存,但内核重启失败:{error}',
|
||||
'config.error.saveFailed': '保存失败:{error}',
|
||||
@@ -288,6 +290,8 @@ export const zhCN = {
|
||||
'config.notice.keyCopyFailed': '复制鉴权密钥失败',
|
||||
'config.notice.routingUpdated': '路由策略已更新',
|
||||
'config.notice.networkUpdated': '网络与会话路由设置已保存',
|
||||
'config.notice.retryUpdated': '失败重试设置已保存',
|
||||
'config.notice.sessionRoutingUpdated': '会话路由设置已保存',
|
||||
'config.notice.networkRestarted': '网络设置已保存,内核已重启',
|
||||
'config.notice.networkNextStart': '网络设置已保存,将在下次启动内核时生效',
|
||||
'config.notice.proxyUpdated': '代理 URL 已保存',
|
||||
@@ -295,6 +299,24 @@ export const zhCN = {
|
||||
'config.notice.sessionAffinityDisabled': '会话粘性路由已停用',
|
||||
'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存',
|
||||
'config.notice.softwareUpdated': '软件设置已保存',
|
||||
'config.tls.enable': '启用 TLS',
|
||||
'config.tls.enableDescription': '使用 HTTPS 提供 API 和 WebUI;需要有效的 PEM 证书与私钥',
|
||||
'config.tls.cert': '证书路径',
|
||||
'config.tls.certPlaceholder': '例如 C:\\certs\\server.crt 或 /path/to/cert.pem',
|
||||
'config.tls.certHint': '填写内核可读取的 PEM 证书文件路径',
|
||||
'config.tls.key': '私钥路径',
|
||||
'config.tls.keyPlaceholder': '例如 C:\\certs\\server.key 或 /path/to/key.pem',
|
||||
'config.tls.keyHint': '填写与证书匹配的 PEM 私钥文件路径',
|
||||
'config.tls.browse': '选择文件',
|
||||
'config.tls.selectCertTitle': '选择 TLS 证书文件',
|
||||
'config.tls.selectKeyTitle': '选择 TLS 私钥文件',
|
||||
'config.tls.certFile': 'TLS 证书',
|
||||
'config.tls.keyFile': 'TLS 私钥',
|
||||
'config.tls.error.selectFileFailed': '选择文件失败:{error}',
|
||||
'config.tls.restartHint': '保存 TLS 设置后,正在运行的内核会自动重启。',
|
||||
'config.tls.error.pathsRequired': '启用 TLS 前必须填写证书和私钥路径',
|
||||
'config.tls.notice.saved': 'TLS 设置已保存,将在下次启动内核时生效',
|
||||
'config.tls.notice.savedAndRestarted': 'TLS 设置已保存,内核已重启',
|
||||
'config.software.title': '软件设置',
|
||||
'config.software.autostart': '开机自启',
|
||||
'config.software.autostartDescription': '登录系统后自动启动 EasyCLIProxyAPI',
|
||||
@@ -345,7 +367,7 @@ export const zhCN = {
|
||||
'common.enabled': '已启用',
|
||||
'common.disabled': '已停用',
|
||||
'config.routing.title': '路由策略',
|
||||
'config.network.title': '网络与会话路由',
|
||||
'config.network.title': '网络设置',
|
||||
'config.network.networkSection': '网络设置',
|
||||
'config.network.routingSection': '会话路由',
|
||||
'config.network.retrySection': '失败重试',
|
||||
@@ -355,8 +377,8 @@ export const zhCN = {
|
||||
'config.network.confirmSave': '确认保存',
|
||||
'config.network.port': '端口',
|
||||
'config.network.portHint': '范围 1–65535;内核运行时修改会自动重启',
|
||||
'config.network.allowLan': '允许局域网',
|
||||
'config.network.allowLanHint': '启用后将监听0.0.0.0',
|
||||
'config.network.listenHost': '自定义监听 IP',
|
||||
'config.network.listenHostHint': '例如 127.0.0.1、0.0.0.0 或局域网 IP;修改后会重启运行中的内核',
|
||||
'config.network.proxyUrl': '代理 URL',
|
||||
'config.network.proxyPlaceholder': '例如 socks5://127.0.0.1:7890',
|
||||
'config.network.proxyHint': '支持socks协议与http协议',
|
||||
@@ -926,6 +948,9 @@ export const zhCN = {
|
||||
'kernel.access.localUrl': '本机 URL',
|
||||
'kernel.access.localCopied': '{name} 本机 URL 已复制',
|
||||
'kernel.access.copyLocal': '复制 {name} 本机 URL',
|
||||
'kernel.access.apiUrl': 'API URL',
|
||||
'kernel.access.apiCopied': '{name} API URL 已复制',
|
||||
'kernel.access.copyApi': '复制 {name} API URL',
|
||||
'kernel.access.lanUrl': '局域网 URL',
|
||||
'kernel.access.detectingIp': '正在检测局域网 IP',
|
||||
'kernel.access.noIp': '未检测到局域网 IP',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const alwaysAvailablePages = new Set(['home', 'versions', 'config']);
|
||||
const alwaysAvailablePages = new Set(['home', 'versions', 'config', 'usage-records']);
|
||||
|
||||
export function isAlwaysAvailablePage(pageId: string) {
|
||||
return alwaysAvailablePages.has(pageId);
|
||||
|
||||
+443
-116
@@ -1,6 +1,7 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import {
|
||||
AlertCircle,
|
||||
Check,
|
||||
@@ -8,8 +9,10 @@ import {
|
||||
Clock3,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
Link2,
|
||||
LockKeyhole,
|
||||
Network,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -30,6 +33,7 @@ import { ThinkingAliasesPage } from './ThinkingAliasesPage';
|
||||
type CoreConfigSettings = {
|
||||
apiKeys: CoreApiKey[];
|
||||
managementSecretConfigured: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
allowLan: boolean;
|
||||
routingStrategy: string;
|
||||
@@ -54,14 +58,16 @@ type ConfigAction =
|
||||
| 'management-secret'
|
||||
| 'routing'
|
||||
| 'network'
|
||||
| 'retry'
|
||||
| 'tls'
|
||||
| 'software'
|
||||
| null;
|
||||
type NoticeTone = 'success' | 'error';
|
||||
type ConfigSubpage = 'general' | 'network' | 'software' | 'aliases';
|
||||
type ConfigSubpage = 'general' | 'network' | 'routing' | 'software' | 'aliases';
|
||||
type CloseBehavior = 'ask' | 'exit' | 'minimize-to-tray';
|
||||
type NetworkDraftField =
|
||||
| 'port'
|
||||
| 'allowLan'
|
||||
| 'host'
|
||||
| 'proxyUrl'
|
||||
| 'sessionAffinity'
|
||||
| 'sessionTtl'
|
||||
@@ -80,9 +86,15 @@ type SoftwareSettings = {
|
||||
silentStartEnabled: boolean;
|
||||
};
|
||||
|
||||
type CoreTlsSettings = {
|
||||
enabled: boolean;
|
||||
cert: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
const cleanNetworkDraft = (): NetworkDraftDirty => ({
|
||||
port: false,
|
||||
allowLan: false,
|
||||
host: false,
|
||||
proxyUrl: false,
|
||||
sessionAffinity: false,
|
||||
sessionTtl: false,
|
||||
@@ -108,6 +120,14 @@ export function ConfigPanelPage() {
|
||||
const [softwareStartCoreDraft, setSoftwareStartCoreDraft] = useState(true);
|
||||
const [softwareSilentStartDraft, setSoftwareSilentStartDraft] = useState(false);
|
||||
const [softwareSavedStatusVisible, setSoftwareSavedStatusVisible] = useState(false);
|
||||
const [tlsSettings, setTlsSettings] = useState<CoreTlsSettings | null>(null);
|
||||
const [tlsSettingsLoading, setTlsSettingsLoading] = useState(true);
|
||||
const [tlsEnabledDraft, setTlsEnabledDraft] = useState(false);
|
||||
const [tlsCertDraft, setTlsCertDraft] = useState('');
|
||||
const [tlsKeyDraft, setTlsKeyDraft] = useState('');
|
||||
const [tlsError, setTlsError] = useState('');
|
||||
const [tlsFileSelecting, setTlsFileSelecting] = useState<'cert' | 'key' | null>(null);
|
||||
const [tlsSavedStatusVisible, setTlsSavedStatusVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [busyAction, setBusyAction] = useState<ConfigAction>(null);
|
||||
@@ -126,7 +146,7 @@ export function ConfigPanelPage() {
|
||||
const [notice, setNotice] = useState<{ message: string; tone: NoticeTone } | null>(null);
|
||||
const [activeSubpage, setActiveSubpage] = useState<ConfigSubpage>('general');
|
||||
const [portDraft, setPortDraft] = useState('8317');
|
||||
const [allowLanDraft, setAllowLanDraft] = useState(false);
|
||||
const [hostDraft, setHostDraft] = useState('127.0.0.1');
|
||||
const [proxyUrlDraft, setProxyUrlDraft] = useState('');
|
||||
const [sessionAffinityDraft, setSessionAffinityDraft] = useState(false);
|
||||
const [sessionTtlDraft, setSessionTtlDraft] = useState('');
|
||||
@@ -135,8 +155,8 @@ export function ConfigPanelPage() {
|
||||
const [maxRetryIntervalDraft, setMaxRetryIntervalDraft] = useState('30');
|
||||
const [streamingBootstrapRetriesDraft, setStreamingBootstrapRetriesDraft] = useState('0');
|
||||
const [portError, setPortError] = useState('');
|
||||
const [hostError, setHostError] = useState('');
|
||||
const [retryError, setRetryError] = useState('');
|
||||
const [savedStatusVisible, setSavedStatusVisible] = useState(false);
|
||||
const networkDraftDirtyRef = useRef<NetworkDraftDirty>(cleanNetworkDraft());
|
||||
const noticeTimerRef = useRef<number | null>(null);
|
||||
const copyTimerRef = useRef<number | null>(null);
|
||||
@@ -146,10 +166,12 @@ export function ConfigPanelPage() {
|
||||
let stop: (() => void) | null = null;
|
||||
void loadSettings();
|
||||
void loadSoftwareSettings();
|
||||
void loadTlsSettings();
|
||||
void listen('config-files-changed', () => {
|
||||
if (!disposed) {
|
||||
void loadSettings('preserve');
|
||||
void loadSoftwareSettings();
|
||||
void loadTlsSettings();
|
||||
}
|
||||
}).then((unlisten) => {
|
||||
if (disposed) unlisten();
|
||||
@@ -183,7 +205,7 @@ export function ConfigPanelPage() {
|
||||
if (mode === 'preserve') {
|
||||
const dirty = networkDraftDirtyRef.current;
|
||||
if (!dirty.port) setPortDraft(String(result.port));
|
||||
if (!dirty.allowLan) setAllowLanDraft(result.allowLan);
|
||||
if (!dirty.host) setHostDraft(result.host);
|
||||
if (!dirty.proxyUrl) setProxyUrlDraft(result.proxyUrl);
|
||||
if (!dirty.sessionAffinity) setSessionAffinityDraft(result.routingSessionAffinity);
|
||||
if (!dirty.sessionTtl) setSessionTtlDraft(result.routingSessionAffinityTtl);
|
||||
@@ -197,7 +219,7 @@ export function ConfigPanelPage() {
|
||||
}
|
||||
networkDraftDirtyRef.current = cleanNetworkDraft();
|
||||
setPortDraft(String(result.port));
|
||||
setAllowLanDraft(result.allowLan);
|
||||
setHostDraft(result.host);
|
||||
setProxyUrlDraft(result.proxyUrl);
|
||||
setSessionAffinityDraft(result.routingSessionAffinity);
|
||||
setSessionTtlDraft(result.routingSessionAffinityTtl);
|
||||
@@ -206,12 +228,12 @@ export function ConfigPanelPage() {
|
||||
setMaxRetryIntervalDraft(String(result.maxRetryInterval));
|
||||
setStreamingBootstrapRetriesDraft(String(result.streamingBootstrapRetries));
|
||||
setPortError('');
|
||||
setHostError('');
|
||||
setRetryError('');
|
||||
};
|
||||
|
||||
const markDraftDirty = (field: NetworkDraftField) => {
|
||||
networkDraftDirtyRef.current[field] = true;
|
||||
setSavedStatusVisible(false);
|
||||
};
|
||||
|
||||
const clearDraftDirty = (field: NetworkDraftField) => {
|
||||
@@ -250,6 +272,23 @@ export function ConfigPanelPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTlsSettings() {
|
||||
setTlsSettingsLoading(true);
|
||||
try {
|
||||
const result = await invoke<CoreTlsSettings>('get_core_tls_settings');
|
||||
setTlsSettings(result);
|
||||
setTlsEnabledDraft(result.enabled);
|
||||
setTlsCertDraft(result.cert);
|
||||
setTlsKeyDraft(result.key);
|
||||
setTlsError('');
|
||||
} catch (error) {
|
||||
setTlsSettings(null);
|
||||
setTlsError(String(error));
|
||||
} finally {
|
||||
setTlsSettingsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const runMutation = async (
|
||||
action: Exclude<ConfigAction, null>,
|
||||
command: string,
|
||||
@@ -428,32 +467,110 @@ export function ConfigPanelPage() {
|
||||
|
||||
const openWebUi = async () => {
|
||||
try {
|
||||
const latestSettings = await invoke<CoreConfigSettings>('get_core_config_settings');
|
||||
const [latestSettings, latestTlsSettings] = await Promise.all([
|
||||
invoke<CoreConfigSettings>('get_core_config_settings'),
|
||||
invoke<CoreTlsSettings>('get_core_tls_settings'),
|
||||
]);
|
||||
applySettings(latestSettings, 'preserve');
|
||||
setTlsSettings(latestTlsSettings);
|
||||
setTlsEnabledDraft(latestTlsSettings.enabled);
|
||||
setTlsCertDraft(latestTlsSettings.cert);
|
||||
setTlsKeyDraft(latestTlsSettings.key);
|
||||
await invoke('open_external_url', {
|
||||
url: webUiManagementUrl(latestSettings.port),
|
||||
url: webUiManagementUrl(latestSettings.port, latestTlsSettings.enabled, latestSettings.host),
|
||||
});
|
||||
} catch (error) {
|
||||
showNotice(t('config.webuiKey.error.openFailed', { error: String(error) }), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const saveTlsSettings = async () => {
|
||||
if (tlsSettings === null || busyAction !== null) return;
|
||||
const cert = tlsCertDraft.trim();
|
||||
const key = tlsKeyDraft.trim();
|
||||
if (tlsEnabledDraft && (!cert || !key)) {
|
||||
setTlsError(t('config.tls.error.pathsRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyAction('tls');
|
||||
setTlsError('');
|
||||
setTlsSavedStatusVisible(false);
|
||||
try {
|
||||
const result = await invoke<CoreTlsSettings>('save_core_tls_settings', {
|
||||
settings: { enabled: tlsEnabledDraft, cert, key },
|
||||
});
|
||||
setTlsSettings(result);
|
||||
setTlsEnabledDraft(result.enabled);
|
||||
setTlsCertDraft(result.cert);
|
||||
setTlsKeyDraft(result.key);
|
||||
if (coreStatus?.running) {
|
||||
const status = await invoke<CoreStatus>('restart_core_process');
|
||||
publishStatus(status);
|
||||
showNotice(t('config.tls.notice.savedAndRestarted'), 'success');
|
||||
} else {
|
||||
showNotice(t('config.tls.notice.saved'), 'success');
|
||||
}
|
||||
setTlsSavedStatusVisible(true);
|
||||
} catch (error) {
|
||||
setTlsError(String(error));
|
||||
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
|
||||
void refreshStatus();
|
||||
void loadTlsSettings();
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const selectTlsFile = async (target: 'cert' | 'key') => {
|
||||
if (tlsSettings === null || busyAction !== null || tlsFileSelecting !== null) return;
|
||||
setTlsFileSelecting(target);
|
||||
setTlsError('');
|
||||
try {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
directory: false,
|
||||
title: target === 'cert'
|
||||
? t('config.tls.selectCertTitle')
|
||||
: t('config.tls.selectKeyTitle'),
|
||||
filters: [{
|
||||
name: target === 'cert' ? t('config.tls.certFile') : t('config.tls.keyFile'),
|
||||
extensions: target === 'cert' ? ['pem', 'crt', 'cer'] : ['pem', 'key'],
|
||||
}],
|
||||
});
|
||||
if (typeof selected !== 'string') return;
|
||||
setTlsSavedStatusVisible(false);
|
||||
if (target === 'cert') setTlsCertDraft(selected);
|
||||
else setTlsKeyDraft(selected);
|
||||
} catch (error) {
|
||||
const message = t('config.tls.error.selectFileFailed', { error: String(error) });
|
||||
setTlsError(message);
|
||||
showNotice(message, 'error');
|
||||
} finally {
|
||||
setTlsFileSelecting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const changeRoutingStrategy = async (strategy: string) => {
|
||||
if (strategy === settings?.routingStrategy) {
|
||||
return;
|
||||
}
|
||||
setSavedStatusVisible(false);
|
||||
const saved = await runMutation(
|
||||
await runMutation(
|
||||
'routing',
|
||||
'set_core_routing_strategy',
|
||||
{ strategy },
|
||||
t('config.notice.routingUpdated'),
|
||||
);
|
||||
if (saved) setSavedStatusVisible(true);
|
||||
};
|
||||
|
||||
const saveNetworkRoutingSettings = async () => {
|
||||
const saveNetworkEndpointSettings = async () => {
|
||||
if (!settings || busyAction !== null) return;
|
||||
const host = hostDraft.trim();
|
||||
if (!host) {
|
||||
setHostError(t('config.error.hostRequired'));
|
||||
showNotice(t('config.error.hostRequired'), 'error');
|
||||
return;
|
||||
}
|
||||
const port = Number(portDraft);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
setPortError(t('config.error.portRange'));
|
||||
@@ -462,45 +579,19 @@ export function ConfigPanelPage() {
|
||||
}
|
||||
|
||||
const proxyUrl = proxyUrlDraft.trim();
|
||||
const routingSessionAffinityTtl = sessionTtlDraft.trim();
|
||||
const retryDrafts = [
|
||||
requestRetryDraft,
|
||||
maxRetryCredentialsDraft,
|
||||
maxRetryIntervalDraft,
|
||||
streamingBootstrapRetriesDraft,
|
||||
];
|
||||
const retryValues = retryDrafts.map(Number);
|
||||
if (
|
||||
retryDrafts.some((value) => value.length === 0)
|
||||
|| retryValues.some((value) => !Number.isInteger(value) || value < 0 || value > 4294967295)
|
||||
) {
|
||||
setRetryError(t('config.error.retryRange'));
|
||||
showNotice(t('config.error.retryRange'), 'error');
|
||||
return;
|
||||
}
|
||||
const [requestRetry, maxRetryCredentials, maxRetryInterval, streamingBootstrapRetries] = retryValues;
|
||||
const networkChanged = port !== settings.port || allowLanDraft !== settings.allowLan;
|
||||
const networkChanged = port !== settings.port || host !== settings.host;
|
||||
setHostError('');
|
||||
setPortError('');
|
||||
setRetryError('');
|
||||
setSavedStatusVisible(false);
|
||||
setBusyAction('network');
|
||||
try {
|
||||
const result = await invoke<CoreConfigSettings>('save_network_routing_settings', {
|
||||
settings: {
|
||||
port,
|
||||
allowLan: allowLanDraft,
|
||||
proxyUrl,
|
||||
routingSessionAffinity: sessionAffinityDraft,
|
||||
routingSessionAffinityTtl,
|
||||
requestRetry,
|
||||
maxRetryCredentials,
|
||||
maxRetryInterval,
|
||||
streamingBootstrapRetries,
|
||||
},
|
||||
const result = await invoke<CoreConfigSettings>('save_network_endpoint_settings', {
|
||||
settings: { host, port, proxyUrl },
|
||||
});
|
||||
applySettings(result);
|
||||
clearDraftDirty('host');
|
||||
clearDraftDirty('port');
|
||||
clearDraftDirty('proxyUrl');
|
||||
applySettings(result, 'preserve');
|
||||
setLoadError('');
|
||||
setSavedStatusVisible(true);
|
||||
|
||||
if (networkChanged && coreStatus?.running) {
|
||||
try {
|
||||
@@ -517,9 +608,76 @@ export function ConfigPanelPage() {
|
||||
showNotice(t('config.notice.networkUpdated'), 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
applySettings(settings);
|
||||
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
|
||||
void loadSettings();
|
||||
void loadSettings('preserve');
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveRetrySettings = async () => {
|
||||
if (!settings || busyAction !== null) return;
|
||||
const retryDrafts = [
|
||||
requestRetryDraft,
|
||||
maxRetryCredentialsDraft,
|
||||
maxRetryIntervalDraft,
|
||||
streamingBootstrapRetriesDraft,
|
||||
];
|
||||
const retryValues = retryDrafts.map(Number);
|
||||
if (
|
||||
retryDrafts.some((value) => value.length === 0)
|
||||
|| retryValues.some((value) => !Number.isInteger(value) || value < 0 || value > 4294967295)
|
||||
) {
|
||||
setRetryError(t('config.error.retryRange'));
|
||||
showNotice(t('config.error.retryRange'), 'error');
|
||||
return;
|
||||
}
|
||||
const [requestRetry, maxRetryCredentials, maxRetryInterval, streamingBootstrapRetries] = retryValues;
|
||||
setRetryError('');
|
||||
setBusyAction('retry');
|
||||
try {
|
||||
const result = await invoke<CoreConfigSettings>('save_retry_settings', {
|
||||
settings: {
|
||||
requestRetry,
|
||||
maxRetryCredentials,
|
||||
maxRetryInterval,
|
||||
streamingBootstrapRetries,
|
||||
},
|
||||
});
|
||||
clearDraftDirty('requestRetry');
|
||||
clearDraftDirty('maxRetryCredentials');
|
||||
clearDraftDirty('maxRetryInterval');
|
||||
clearDraftDirty('streamingBootstrapRetries');
|
||||
applySettings(result, 'preserve');
|
||||
setLoadError('');
|
||||
showNotice(t('config.notice.retryUpdated'), 'success');
|
||||
} catch (error) {
|
||||
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
|
||||
void loadSettings('preserve');
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSessionRoutingSettings = async () => {
|
||||
if (!settings || busyAction !== null) return;
|
||||
const routingSessionAffinityTtl = sessionTtlDraft.trim();
|
||||
setBusyAction('routing');
|
||||
try {
|
||||
const result = await invoke<CoreConfigSettings>('save_session_routing_settings', {
|
||||
settings: {
|
||||
routingSessionAffinity: sessionAffinityDraft,
|
||||
routingSessionAffinityTtl,
|
||||
},
|
||||
});
|
||||
clearDraftDirty('sessionAffinity');
|
||||
clearDraftDirty('sessionTtl');
|
||||
applySettings(result, 'preserve');
|
||||
setLoadError('');
|
||||
showNotice(t('config.notice.sessionRoutingUpdated'), 'success');
|
||||
} catch (error) {
|
||||
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
|
||||
void loadSettings('preserve');
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
@@ -565,28 +723,21 @@ export function ConfigPanelPage() {
|
||||
};
|
||||
|
||||
const controlsDisabled = loading || settings === null || busyAction !== null;
|
||||
const networkRoutingDirty = Boolean(settings) && (
|
||||
portDraft !== String(settings?.port)
|
||||
|| allowLanDraft !== settings?.allowLan
|
||||
const networkSettingsDirty = Boolean(settings) && (
|
||||
hostDraft.trim() !== settings?.host
|
||||
|| portDraft !== String(settings?.port)
|
||||
|| proxyUrlDraft.trim() !== settings?.proxyUrl
|
||||
|| sessionAffinityDraft !== settings?.routingSessionAffinity
|
||||
);
|
||||
const sessionRoutingDirty = Boolean(settings) && (
|
||||
sessionAffinityDraft !== settings?.routingSessionAffinity
|
||||
|| sessionTtlDraft.trim() !== settings?.routingSessionAffinityTtl
|
||||
|| requestRetryDraft !== String(settings?.requestRetry)
|
||||
);
|
||||
const retrySettingsDirty = Boolean(settings) && (
|
||||
requestRetryDraft !== String(settings?.requestRetry)
|
||||
|| maxRetryCredentialsDraft !== String(settings?.maxRetryCredentials)
|
||||
|| maxRetryIntervalDraft !== String(settings?.maxRetryInterval)
|
||||
|| streamingBootstrapRetriesDraft !== String(settings?.streamingBootstrapRetries)
|
||||
);
|
||||
const networkStatusLabel = loading
|
||||
? t('common.loading')
|
||||
: settings === null
|
||||
? t('common.unavailable')
|
||||
: busyAction === 'network' || busyAction === 'routing'
|
||||
? t('config.network.saving')
|
||||
: networkRoutingDirty
|
||||
? t('config.network.unsaved')
|
||||
: savedStatusVisible
|
||||
? t('config.network.saved')
|
||||
: '';
|
||||
const softwareCloseBehaviorDirty = softwareSettings !== null
|
||||
&& softwareCloseBehaviorDraft !== softwareSettings.closeBehavior;
|
||||
const softwareAutostartDirty = softwareSettings !== null
|
||||
@@ -612,11 +763,24 @@ export function ConfigPanelPage() {
|
||||
&& softwareSettings !== null
|
||||
&& !softwareSettingsDirty
|
||||
&& softwareSavedStatusVisible;
|
||||
const networkStatusIsSaved = !loading
|
||||
&& settings !== null
|
||||
&& busyAction === null
|
||||
&& !networkRoutingDirty
|
||||
&& savedStatusVisible;
|
||||
const tlsSettingsDirty = tlsSettings !== null && (
|
||||
tlsEnabledDraft !== tlsSettings.enabled
|
||||
|| tlsCertDraft.trim() !== tlsSettings.cert
|
||||
|| tlsKeyDraft.trim() !== tlsSettings.key
|
||||
);
|
||||
const tlsStatusLabel = tlsSettingsLoading
|
||||
? t('common.loading')
|
||||
: tlsSettings === null
|
||||
? t('common.unavailable')
|
||||
: tlsSettingsDirty
|
||||
? t('config.network.unsaved')
|
||||
: tlsSavedStatusVisible
|
||||
? t('config.network.saved')
|
||||
: '';
|
||||
const tlsStatusIsSaved = !tlsSettingsLoading
|
||||
&& tlsSettings !== null
|
||||
&& !tlsSettingsDirty
|
||||
&& tlsSavedStatusVisible;
|
||||
const selectedDeleteKey =
|
||||
deleteIndex === null ? '' : settings?.apiKeys[deleteIndex]?.apiKey || '';
|
||||
const deletingLastKey = deleteIndex !== null && settings?.apiKeys.length === 1;
|
||||
@@ -650,6 +814,18 @@ export function ConfigPanelPage() {
|
||||
>
|
||||
{t('config.tabs.network')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="config-subpage-tab-routing"
|
||||
role="tab"
|
||||
className={activeSubpage === 'routing' ? 'active' : ''}
|
||||
aria-selected={activeSubpage === 'routing'}
|
||||
aria-controls="config-subpage-panel-routing"
|
||||
tabIndex={activeSubpage === 'routing' ? 0 : -1}
|
||||
onClick={() => setActiveSubpage('routing')}
|
||||
>
|
||||
{t('config.tabs.routing')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="config-subpage-tab-aliases"
|
||||
@@ -805,7 +981,7 @@ export function ConfigPanelPage() {
|
||||
className="secondary-button compact-button"
|
||||
disabled={loading || settings === null}
|
||||
onClick={() => void openWebUi()}
|
||||
title={settings ? webUiManagementUrl(settings.port) : undefined}
|
||||
title={settings ? webUiManagementUrl(settings.port, tlsSettings?.enabled, settings.host) : undefined}
|
||||
>
|
||||
{t('config.webuiKey.open')}
|
||||
</button>
|
||||
@@ -904,45 +1080,35 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : activeSubpage === 'network' ? (
|
||||
) : activeSubpage === 'network' || activeSubpage === 'routing' ? (
|
||||
<div
|
||||
className="config-subpage-panel config-network-subpage"
|
||||
id="config-subpage-panel-network"
|
||||
id={`config-subpage-panel-${activeSubpage}`}
|
||||
role="tabpanel"
|
||||
aria-labelledby="config-subpage-tab-network"
|
||||
aria-labelledby={`config-subpage-tab-${activeSubpage}`}
|
||||
>
|
||||
<section className="panel config-network-panel">
|
||||
<div className="config-panel-heading">
|
||||
<div className="config-heading-title">
|
||||
<Network size={18} aria-hidden="true" />
|
||||
<h2>{t('config.network.title')}</h2>
|
||||
<div className="config-network-panel">
|
||||
<div className="config-network-sections">
|
||||
<section
|
||||
className="config-network-section"
|
||||
aria-labelledby="config-network-section-title"
|
||||
hidden={activeSubpage !== 'network'}
|
||||
>
|
||||
<div className="config-network-section-heading config-section-heading-with-actions">
|
||||
<div className="config-network-section-title">
|
||||
<Network size={16} aria-hidden="true" />
|
||||
<h3 id="config-network-section-title">{t('config.network.networkSection')}</h3>
|
||||
</div>
|
||||
<div className="config-heading-actions">
|
||||
{networkStatusLabel ? (
|
||||
<span className={`state-pill ${networkStatusIsSaved ? 'success' : ''}`}>
|
||||
{networkStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !networkRoutingDirty}
|
||||
onClick={() => void saveNetworkRoutingSettings()}
|
||||
disabled={controlsDisabled || !networkSettingsDirty}
|
||||
onClick={() => void saveNetworkEndpointSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'network'
|
||||
? t('config.network.saving')
|
||||
: t('config.network.confirmSave')}
|
||||
{busyAction === 'network' ? t('common.saving') : t('config.network.confirmSave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-network-sections">
|
||||
<section className="config-network-section" aria-labelledby="config-network-section-title">
|
||||
<div className="config-network-section-heading">
|
||||
<Network size={16} aria-hidden="true" />
|
||||
<h3 id="config-network-section-title">{t('config.network.networkSection')}</h3>
|
||||
</div>
|
||||
<div className="config-network-grid">
|
||||
<label className="config-network-field config-network-port-field">
|
||||
<span>{t('config.network.port')}</span>
|
||||
@@ -973,25 +1139,32 @@ export function ConfigPanelPage() {
|
||||
<small>{t('config.network.portHint')}</small>
|
||||
</label>
|
||||
|
||||
<div className="config-network-field config-network-toggle">
|
||||
<div>
|
||||
<span>{t('config.network.allowLan')}</span>
|
||||
<small>{t('config.network.allowLanHint')}</small>
|
||||
</div>
|
||||
<label className="switch-control" title={t('config.network.allowLan')}>
|
||||
<label className="config-network-field">
|
||||
<span>{t('config.network.listenHost')}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('config.network.allowLan')}
|
||||
checked={allowLanDraft}
|
||||
className={`config-network-input ${hostError ? 'error' : ''}`}
|
||||
type="text"
|
||||
value={hostDraft}
|
||||
disabled={controlsDisabled}
|
||||
placeholder="127.0.0.1"
|
||||
aria-invalid={Boolean(hostError)}
|
||||
title={hostError || t('config.network.listenHostHint')}
|
||||
onChange={(event) => {
|
||||
markDraftDirty('allowLan');
|
||||
setAllowLanDraft(event.currentTarget.checked);
|
||||
markDraftDirty('host');
|
||||
setHostDraft(event.currentTarget.value);
|
||||
setHostError('');
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && settings) {
|
||||
clearDraftDirty('host');
|
||||
setHostDraft(settings.host);
|
||||
setHostError('');
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
<small>{t('config.network.listenHostHint')}</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="config-network-field">
|
||||
<span className="config-network-label">
|
||||
@@ -1021,11 +1194,26 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-network-section" aria-labelledby="config-routing-section-title">
|
||||
<div className="config-network-section-heading">
|
||||
<section
|
||||
className="config-network-section"
|
||||
aria-labelledby="config-routing-section-title"
|
||||
hidden={activeSubpage !== 'routing'}
|
||||
>
|
||||
<div className="config-network-section-heading config-section-heading-with-actions">
|
||||
<div className="config-network-section-title">
|
||||
<Route size={16} aria-hidden="true" />
|
||||
<h3 id="config-routing-section-title">{t('config.network.routingSection')}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !sessionRoutingDirty}
|
||||
onClick={() => void saveSessionRoutingSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'routing' ? t('common.saving') : t('config.network.confirmSave')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="config-network-grid">
|
||||
<div className="config-network-field config-network-toggle">
|
||||
<div>
|
||||
@@ -1104,11 +1292,26 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="config-network-section" aria-labelledby="config-retry-section-title">
|
||||
<div className="config-network-section-heading">
|
||||
<section
|
||||
className="config-network-section"
|
||||
aria-labelledby="config-retry-section-title"
|
||||
hidden={activeSubpage !== 'network'}
|
||||
>
|
||||
<div className="config-network-section-heading config-section-heading-with-actions">
|
||||
<div className="config-network-section-title">
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
<h3 id="config-retry-section-title">{t('config.network.retrySection')}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={controlsDisabled || !retrySettingsDirty}
|
||||
onClick={() => void saveRetrySettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'retry' ? t('common.saving') : t('config.network.confirmSave')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="config-network-grid">
|
||||
<label className="config-network-field">
|
||||
<span>{t('config.network.requestRetry')}</span>
|
||||
@@ -1223,9 +1426,133 @@ export function ConfigPanelPage() {
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
className="config-network-section"
|
||||
aria-labelledby="config-tls-section-title"
|
||||
hidden={activeSubpage !== 'network'}
|
||||
>
|
||||
<div className="config-network-section-heading config-tls-section-heading">
|
||||
<div className="config-tls-section-title">
|
||||
<LockKeyhole size={18} aria-hidden="true" />
|
||||
<h3 id="config-tls-section-title">{t('config.tls.enable')}</h3>
|
||||
</div>
|
||||
<div className="config-heading-actions">
|
||||
{tlsStatusLabel ? (
|
||||
<span className={`state-pill ${tlsStatusIsSaved ? 'success' : ''}`}>
|
||||
{tlsStatusLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button compact-button"
|
||||
disabled={tlsSettingsLoading || tlsSettings === null || busyAction !== null || tlsFileSelecting !== null || !tlsSettingsDirty}
|
||||
onClick={() => void saveTlsSettings()}
|
||||
>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{busyAction === 'tls' ? t('common.saving') : t('config.network.confirmSave')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="config-tls-content">
|
||||
<div className="config-software-setting-row config-tls-toggle-row">
|
||||
<div className="config-software-setting-copy">
|
||||
<span className="config-software-setting-icon" aria-hidden="true">
|
||||
<ShieldCheck size={18} />
|
||||
</span>
|
||||
<div>
|
||||
<strong>{t('config.tls.enable')}</strong>
|
||||
<small>{t('config.tls.enableDescription')}</small>
|
||||
</div>
|
||||
</div>
|
||||
<label className="switch-control" title={t('config.tls.enable')}>
|
||||
<input
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-label={t('config.tls.enable')}
|
||||
checked={tlsEnabledDraft}
|
||||
disabled={tlsSettingsLoading || tlsSettings === null || busyAction !== null}
|
||||
onChange={(event) => {
|
||||
setTlsSavedStatusVisible(false);
|
||||
setTlsError('');
|
||||
setTlsEnabledDraft(event.currentTarget.checked);
|
||||
}}
|
||||
/>
|
||||
<span className="switch-track" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{tlsEnabledDraft ? (
|
||||
<div className="config-tls-fields">
|
||||
<div className="config-network-field">
|
||||
<span>{t('config.tls.cert')}</span>
|
||||
<div className="config-tls-path-control">
|
||||
<input
|
||||
className={`config-network-input ${tlsError && !tlsCertDraft.trim() ? 'error' : ''}`}
|
||||
type="text"
|
||||
value={tlsCertDraft}
|
||||
aria-label={t('config.tls.cert')}
|
||||
disabled={tlsSettingsLoading || tlsSettings === null || busyAction !== null || tlsFileSelecting !== null}
|
||||
placeholder={t('config.tls.certPlaceholder')}
|
||||
onChange={(event) => {
|
||||
setTlsSavedStatusVisible(false);
|
||||
setTlsError('');
|
||||
setTlsCertDraft(event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button config-tls-browse-button"
|
||||
disabled={tlsSettingsLoading || tlsSettings === null || busyAction !== null || tlsFileSelecting !== null}
|
||||
title={t('config.tls.selectCertTitle')}
|
||||
onClick={() => void selectTlsFile('cert')}
|
||||
>
|
||||
<FolderOpen size={16} aria-hidden="true" />
|
||||
{tlsFileSelecting === 'cert' ? t('common.processing') : t('config.tls.browse')}
|
||||
</button>
|
||||
</div>
|
||||
<small>{t('config.tls.certHint')}</small>
|
||||
</div>
|
||||
<div className="config-network-field">
|
||||
<span>{t('config.tls.key')}</span>
|
||||
<div className="config-tls-path-control">
|
||||
<input
|
||||
className={`config-network-input ${tlsError && !tlsKeyDraft.trim() ? 'error' : ''}`}
|
||||
type="text"
|
||||
value={tlsKeyDraft}
|
||||
aria-label={t('config.tls.key')}
|
||||
disabled={tlsSettingsLoading || tlsSettings === null || busyAction !== null || tlsFileSelecting !== null}
|
||||
placeholder={t('config.tls.keyPlaceholder')}
|
||||
onChange={(event) => {
|
||||
setTlsSavedStatusVisible(false);
|
||||
setTlsError('');
|
||||
setTlsKeyDraft(event.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-button compact-button config-tls-browse-button"
|
||||
disabled={tlsSettingsLoading || tlsSettings === null || busyAction !== null || tlsFileSelecting !== null}
|
||||
title={t('config.tls.selectKeyTitle')}
|
||||
onClick={() => void selectTlsFile('key')}
|
||||
>
|
||||
<FolderOpen size={16} aria-hidden="true" />
|
||||
{tlsFileSelecting === 'key' ? t('common.processing') : t('config.tls.browse')}
|
||||
</button>
|
||||
</div>
|
||||
<small>{t('config.tls.keyHint')}</small>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`config-tls-message ${tlsError ? 'error' : ''}`} role={tlsError ? 'alert' : undefined}>
|
||||
{tlsError || t('config.tls.restartHint')}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeSubpage === 'software' ? (
|
||||
<div
|
||||
className="config-subpage-panel"
|
||||
|
||||
+27
-74
@@ -56,8 +56,8 @@ type MessageType = 'info' | 'success' | 'error';
|
||||
type CoreProcessCommand = 'start_core_process' | 'stop_core_process' | 'restart_core_process';
|
||||
|
||||
type GuiSettings = {
|
||||
host: string;
|
||||
port: number;
|
||||
allowLan: boolean;
|
||||
runOnStartup: boolean;
|
||||
};
|
||||
|
||||
@@ -65,6 +65,12 @@ type CoreConfigSummary = {
|
||||
apiKeys: Array<{ apiKey: string }>;
|
||||
};
|
||||
|
||||
type CoreTlsSettings = {
|
||||
enabled: boolean;
|
||||
cert: string;
|
||||
key: string;
|
||||
};
|
||||
|
||||
const APP_RELEASE_URL = 'https://github.com/router-for-me/EasyCLIProxyAPI/releases/latest';
|
||||
|
||||
let latestAutoCheckStarted = false;
|
||||
@@ -126,7 +132,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
const [bundledCore, setBundledCore] = useState<BundledCoreInfo | null>(null);
|
||||
const [bundledCoreError, setBundledCoreError] = useState('');
|
||||
const [checkingLatest, setCheckingLatest] = useState(Boolean(latestCheckPromise));
|
||||
const [allowLanAccess, setAllowLanAccess] = useState(false);
|
||||
const [listenHost, setListenHost] = useState('127.0.0.1');
|
||||
const [customPort, setCustomPort] = useState('8317');
|
||||
const [installing, setInstalling] = useState(false);
|
||||
const [processBusy, setProcessBusy] = useState(false);
|
||||
@@ -146,8 +152,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
const [homeApiKey, setHomeApiKey] = useState<string | null | undefined>(undefined);
|
||||
const [homeApiKeyError, setHomeApiKeyError] = useState(false);
|
||||
const [showHomeApiKey, setShowHomeApiKey] = useState(false);
|
||||
const [lanIpv4, setLanIpv4] = useState<string | null>(null);
|
||||
const [lanIpChecked, setLanIpChecked] = useState(false);
|
||||
const [tlsEnabled, setTlsEnabled] = useState(false);
|
||||
const installDialogRef = useRef<HTMLDivElement>(null);
|
||||
const savedPortRef = useRef(8317);
|
||||
const processNoticeTimerRef = useRef<number | null>(null);
|
||||
@@ -223,6 +228,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
void listen('config-files-changed', () => {
|
||||
if (disposed) return;
|
||||
void loadGuiSettings();
|
||||
void loadTlsSettings();
|
||||
void refreshStatus();
|
||||
if (view === 'home') void loadHomeApiKey();
|
||||
}).then((stop) => {
|
||||
@@ -234,6 +240,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
loadBundledCore();
|
||||
loadInstallTask();
|
||||
loadGuiSettings();
|
||||
void loadTlsSettings();
|
||||
void getVersion()
|
||||
.then((version) => {
|
||||
if (!disposed) setInstalledAppVersion(version);
|
||||
@@ -286,37 +293,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
};
|
||||
}, [installDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
if (!allowLanAccess) {
|
||||
setLanIpv4(null);
|
||||
setLanIpChecked(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLanIpChecked(false);
|
||||
invoke<string | null>('get_lan_ipv4')
|
||||
.then((address) => {
|
||||
if (!disposed) {
|
||||
setLanIpv4(address || null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!disposed) {
|
||||
setLanIpv4(null);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) {
|
||||
setLanIpChecked(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [allowLanAccess]);
|
||||
|
||||
const runCoreProcessCommand = async (
|
||||
command: CoreProcessCommand,
|
||||
messages?: { success?: string; failure?: string },
|
||||
@@ -352,7 +328,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
const loadGuiSettings = async () => {
|
||||
try {
|
||||
const settings = await invoke<GuiSettings>('get_gui_settings');
|
||||
setAllowLanAccess(settings.allowLan);
|
||||
setListenHost(settings.host);
|
||||
setCustomPort(String(settings.port));
|
||||
savedPortRef.current = settings.port;
|
||||
} catch {}
|
||||
@@ -369,6 +345,15 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadTlsSettings = async () => {
|
||||
try {
|
||||
const settings = await invoke<CoreTlsSettings>('get_core_tls_settings');
|
||||
setTlsEnabled(settings.enabled);
|
||||
} catch {
|
||||
setTlsEnabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlatform = async () => {
|
||||
try {
|
||||
const result = await invoke<CorePlatform>('detect_core_platform');
|
||||
@@ -699,7 +684,8 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
Number.isInteger(apiPort) && apiPort >= 1 && apiPort <= 65535
|
||||
? apiPort
|
||||
: savedPortRef.current,
|
||||
allowLanAccess ? lanIpv4 : null,
|
||||
tlsEnabled,
|
||||
listenHost,
|
||||
);
|
||||
const apiProfileIcons = {
|
||||
openai: openaiIcon,
|
||||
@@ -1030,7 +1016,7 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
|
||||
<div className="client-api-values">
|
||||
<div className="client-api-value-row">
|
||||
<span>{t('kernel.access.localUrl')}</span>
|
||||
<span>{t('kernel.access.apiUrl')}</span>
|
||||
<code title={profile.baseUrl}>{profile.baseUrl}</code>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1039,11 +1025,11 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
void copyApiValue(
|
||||
profile.baseUrl,
|
||||
`${profile.id}:base`,
|
||||
t('kernel.access.localCopied', { name: profile.name }),
|
||||
t('kernel.access.apiCopied', { name: profile.name }),
|
||||
)
|
||||
}
|
||||
title={t('kernel.access.copyLocal', { name: profile.name })}
|
||||
aria-label={t('kernel.access.copyLocal', { name: profile.name })}
|
||||
title={t('kernel.access.copyApi', { name: profile.name })}
|
||||
aria-label={t('kernel.access.copyApi', { name: profile.name })}
|
||||
>
|
||||
{copiedApiField === `${profile.id}:base` ? (
|
||||
<Check size={15} aria-hidden="true" />
|
||||
@@ -1052,39 +1038,6 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{allowLanAccess ? (
|
||||
<div className="client-api-value-row">
|
||||
<span>{t('kernel.access.lanUrl')}</span>
|
||||
<code title={profile.lanUrl || undefined}>
|
||||
{!lanIpChecked
|
||||
? t('kernel.access.detectingIp')
|
||||
: profile.lanUrl || t('kernel.access.noIp')}
|
||||
</code>
|
||||
{profile.lanUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button quiet"
|
||||
onClick={() =>
|
||||
void copyApiValue(
|
||||
profile.lanUrl!,
|
||||
`${profile.id}:lan`,
|
||||
t('kernel.access.lanCopied', { name: profile.name }),
|
||||
)
|
||||
}
|
||||
title={t('kernel.access.copyLan', { name: profile.name })}
|
||||
aria-label={t('kernel.access.copyLan', { name: profile.name })}
|
||||
>
|
||||
{copiedApiField === `${profile.id}:lan` ? (
|
||||
<Check size={15} aria-hidden="true" />
|
||||
) : (
|
||||
<Copy size={15} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="client-api-copy-placeholder" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
@@ -5,20 +5,39 @@ export type ClientApiProfile = {
|
||||
name: string;
|
||||
description: string;
|
||||
baseUrl: string;
|
||||
lanUrl: string | null;
|
||||
};
|
||||
|
||||
const safeLocalPort = (port: number) =>
|
||||
Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 8317;
|
||||
|
||||
export function webUiManagementUrl(port: number): string {
|
||||
return `http://127.0.0.1:${safeLocalPort(port)}/management.html#/login`;
|
||||
const normalizedConnectHost = (listenHost: string) => {
|
||||
const host = listenHost.trim().replace(/^\[|\]$/g, '');
|
||||
if (!host || host === '0.0.0.0') return '127.0.0.1';
|
||||
if (host === '::') return '::1';
|
||||
return host;
|
||||
};
|
||||
|
||||
const urlHost = (host: string) => host.includes(':') ? `[${host}]` : host;
|
||||
|
||||
export function webUiManagementUrl(
|
||||
port: number,
|
||||
tlsEnabled = false,
|
||||
listenHost = '127.0.0.1',
|
||||
): string {
|
||||
const scheme = tlsEnabled ? 'https' : 'http';
|
||||
const host = urlHost(normalizedConnectHost(listenHost));
|
||||
return `${scheme}://${host}:${safeLocalPort(port)}/management.html#/login`;
|
||||
}
|
||||
|
||||
export function clientApiProfiles(port: number, lanIpv4?: string | null): ClientApiProfile[] {
|
||||
export function clientApiProfiles(
|
||||
port: number,
|
||||
tlsEnabled = false,
|
||||
listenHost = '127.0.0.1',
|
||||
): ClientApiProfile[] {
|
||||
const safePort = safeLocalPort(port);
|
||||
const origin = `http://127.0.0.1:${safePort}`;
|
||||
const lanOrigin = lanIpv4?.trim() ? `http://${lanIpv4.trim()}:${safePort}` : null;
|
||||
const scheme = tlsEnabled ? 'https' : 'http';
|
||||
const connectHost = normalizedConnectHost(listenHost);
|
||||
const origin = `${scheme}://${urlHost(connectHost)}:${safePort}`;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -26,21 +45,18 @@ export function clientApiProfiles(port: number, lanIpv4?: string | null): Client
|
||||
name: 'OpenAI',
|
||||
description: translate(getCurrentLocale(), 'kernel.access.openaiDescription'),
|
||||
baseUrl: `${origin}/v1`,
|
||||
lanUrl: lanOrigin ? `${lanOrigin}/v1` : null,
|
||||
},
|
||||
{
|
||||
id: 'claude',
|
||||
name: 'Claude',
|
||||
description: translate(getCurrentLocale(), 'kernel.access.claudeDescription'),
|
||||
baseUrl: origin,
|
||||
lanUrl: lanOrigin,
|
||||
},
|
||||
{
|
||||
id: 'gemini',
|
||||
name: 'Gemini',
|
||||
description: translate(getCurrentLocale(), 'kernel.access.geminiDescription'),
|
||||
baseUrl: origin,
|
||||
lanUrl: lanOrigin,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1769,6 +1769,70 @@ textarea:focus-visible {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.config-tls-content {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.config-tls-toggle-row {
|
||||
padding: 4px 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.config-tls-section-heading,
|
||||
.config-section-heading-with-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.config-tls-section-title,
|
||||
.config-network-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-tls-section-title h3,
|
||||
.config-network-section-title h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-section);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
.config-tls-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.config-tls-fields .config-network-field {
|
||||
min-height: 142px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.config-tls-path-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.config-tls-browse-button {
|
||||
min-width: max-content;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.config-tls-message {
|
||||
min-height: 20px;
|
||||
color: var(--theme-6d6760);
|
||||
font-size: var(--font-size-label);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
.config-tls-message.error {
|
||||
color: var(--theme-9f2d20);
|
||||
}
|
||||
|
||||
.config-network-sections {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@@ -1786,6 +1850,10 @@ textarea:focus-visible {
|
||||
background: var(--theme-fffdf8);
|
||||
}
|
||||
|
||||
.config-network-section[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.config-network-section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -7845,6 +7913,10 @@ textarea:focus-visible {
|
||||
grid-auto-rows: auto;
|
||||
}
|
||||
|
||||
.config-tls-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.config-management-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,25 @@ import { clientApiProfiles, webUiManagementUrl } from '../src/services/clientAcc
|
||||
|
||||
describe('客户端 API 接入信息', () => {
|
||||
it('生成 OpenAI、Claude 和 Gemini 三种正确格式', () => {
|
||||
const profiles = clientApiProfiles(9527, '192.168.1.8');
|
||||
const profiles = clientApiProfiles(9527);
|
||||
|
||||
expect(profiles.map((profile) => profile.id)).toEqual(['openai', 'claude', 'gemini']);
|
||||
expect(profiles[0].baseUrl).toBe('http://127.0.0.1:9527/v1');
|
||||
expect(profiles[0].lanUrl).toBe('http://192.168.1.8:9527/v1');
|
||||
expect(profiles[1].baseUrl).toBe('http://127.0.0.1:9527');
|
||||
expect(profiles[1].lanUrl).toBe('http://192.168.1.8:9527');
|
||||
expect(profiles[2].baseUrl).toBe('http://127.0.0.1:9527');
|
||||
expect(profiles[2].lanUrl).toBe('http://192.168.1.8:9527');
|
||||
});
|
||||
|
||||
it('端口无效时回退到 8317,缺少局域网地址时保持为空', () => {
|
||||
const [openai] = clientApiProfiles(0);
|
||||
|
||||
expect(openai.baseUrl).toBe('http://127.0.0.1:8317/v1');
|
||||
expect(openai.lanUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('TLS 开启时生成 HTTPS 客户端地址', () => {
|
||||
const profiles = clientApiProfiles(9527, true);
|
||||
|
||||
expect(profiles[0].baseUrl).toBe('https://127.0.0.1:9527/v1');
|
||||
expect(profiles[1].baseUrl).toBe('https://127.0.0.1:9527');
|
||||
});
|
||||
|
||||
it('使用当前内核端口生成 WebUI 登录地址', () => {
|
||||
@@ -28,5 +31,29 @@ describe('客户端 API 接入信息', () => {
|
||||
expect(webUiManagementUrl(0)).toBe(
|
||||
'http://127.0.0.1:8317/management.html#/login',
|
||||
);
|
||||
expect(webUiManagementUrl(9527, true)).toBe(
|
||||
'https://127.0.0.1:9527/management.html#/login',
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the configured listen IP for API and WebUI URLs', () => {
|
||||
const [openai] = clientApiProfiles(9527, true, '192.168.1.20');
|
||||
|
||||
expect(openai.baseUrl).toBe('https://192.168.1.20:9527/v1');
|
||||
expect(webUiManagementUrl(9527, true, '192.168.1.20')).toBe(
|
||||
'https://192.168.1.20:9527/management.html#/login',
|
||||
);
|
||||
});
|
||||
|
||||
it('converts wildcard and IPv6 listen IPs to connectable URL hosts', () => {
|
||||
expect(webUiManagementUrl(8317, false, '0.0.0.0')).toBe(
|
||||
'http://127.0.0.1:8317/management.html#/login',
|
||||
);
|
||||
expect(webUiManagementUrl(8317, false, '::')).toBe(
|
||||
'http://[::1]:8317/management.html#/login',
|
||||
);
|
||||
expect(clientApiProfiles(8317, false, '2001:db8::1')[0].baseUrl).toBe(
|
||||
'http://[2001:db8::1]:8317/v1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,11 @@ describe('首页、配置与版本管理导航', () => {
|
||||
expect(isAlwaysAvailablePage('home')).toBe(true);
|
||||
expect(isAlwaysAvailablePage('versions')).toBe(true);
|
||||
expect(isAlwaysAvailablePage('config')).toBe(true);
|
||||
expect(isAlwaysAvailablePage('usage-records')).toBe(true);
|
||||
expect(canOpenAppPage('home', false)).toBe(true);
|
||||
expect(canOpenAppPage('versions', false)).toBe(true);
|
||||
expect(canOpenAppPage('config', false)).toBe(true);
|
||||
expect(canOpenAppPage('usage-records', false)).toBe(true);
|
||||
expect(canOpenAppPage('quota', false)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user