* 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:
lzt404 authored and GitHub committed 2026-08-23 17:56:13 +08:00
1 parent c3cd57bd8e
commit 2b9ceb11fb
21 files changed
+1355 -261

No files matched your search

+29 -4
View File
@@ -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
View File
@@ -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',
+29 -4
View File
@@ -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
View File
@@ -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);
+460 -133
View File
@@ -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,44 +1080,34 @@ 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>
<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()}
>
<Check size={16} aria-hidden="true" />
{busyAction === 'network'
? t('config.network.saving')
: t('config.network.confirmSave')}
</button>
</div>
</div>
<div className="config-network-panel">
<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>
<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>
<button
type="button"
className="primary-button compact-button"
disabled={controlsDisabled || !networkSettingsDirty}
onClick={() => void saveNetworkEndpointSettings()}
>
<Check size={16} aria-hidden="true" />
{busyAction === 'network' ? t('common.saving') : t('config.network.confirmSave')}
</button>
</div>
<div className="config-network-grid">
<label className="config-network-field config-network-port-field">
@@ -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')}>
<input
type="checkbox"
aria-label={t('config.network.allowLan')}
checked={allowLanDraft}
disabled={controlsDisabled}
onChange={(event) => {
markDraftDirty('allowLan');
setAllowLanDraft(event.currentTarget.checked);
}}
/>
<span className="switch-track" />
</label>
</div>
<label className="config-network-field">
<span>{t('config.network.listenHost')}</span>
<input
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('host');
setHostDraft(event.currentTarget.value);
setHostError('');
}}
onKeyDown={(event) => {
if (event.key === 'Escape' && settings) {
clearDraftDirty('host');
setHostDraft(settings.host);
setHostError('');
event.currentTarget.blur();
}
}}
/>
<small>{t('config.network.listenHostHint')}</small>
</label>
<label className="config-network-field">
<span className="config-network-label">
@@ -1021,10 +1194,25 @@ export function ConfigPanelPage() {
</div>
</section>
<section className="config-network-section" aria-labelledby="config-routing-section-title">
<div className="config-network-section-heading">
<Route size={16} aria-hidden="true" />
<h3 id="config-routing-section-title">{t('config.network.routingSection')}</h3>
<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">
@@ -1104,10 +1292,25 @@ export function ConfigPanelPage() {
</div>
</section>
<section className="config-network-section" aria-labelledby="config-retry-section-title">
<div className="config-network-section-heading">
<RefreshCw size={16} aria-hidden="true" />
<h3 id="config-retry-section-title">{t('config.network.retrySection')}</h3>
<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">
@@ -1223,8 +1426,132 @@ 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>
</section>
</div>
</div>
) : activeSubpage === 'software' ? (
<div
+27 -74
View File
@@ -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>
))}
+25 -9
View File
@@ -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,
},
];
}
+72
View File
@@ -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;
}