Merge pull request #87 from router-for-me/dev

Dev
This commit is contained in:
lzt404 authored and GitHub committed 2026-08-03 17:18:13 +08:00
commit 9638f8d90c
8 files changed
+537 -24

No files matched your search

+254 -13
View File
@@ -479,6 +479,7 @@ struct GuiConfigFile {
allow_lan: bool,
host: String,
run_on_startup: bool,
close_behavior: WindowsCloseBehavior,
window_width: Option<u32>,
window_height: Option<u32>,
auth_dir: String,
@@ -504,6 +505,30 @@ enum WindowsCloseAction {
MinimizeToTray,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
enum WindowsCloseBehavior {
Ask,
Exit,
MinimizeToTray,
}
impl Default for WindowsCloseBehavior {
fn default() -> Self {
Self::Ask
}
}
impl WindowsCloseBehavior {
fn as_str(self) -> &'static str {
match self {
Self::Ask => "ask",
Self::Exit => "exit",
Self::MinimizeToTray => "minimize-to-tray",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct GuiApiKeyEntry {
key: String,
@@ -567,6 +592,7 @@ impl Default for GuiConfigFile {
allow_lan: false,
host: "127.0.0.1".to_string(),
run_on_startup: false,
close_behavior: WindowsCloseBehavior::Ask,
window_width: Some(DEFAULT_MAIN_WINDOW_WIDTH),
window_height: Some(DEFAULT_MAIN_WINDOW_HEIGHT),
auth_dir: DEFAULT_AUTH_DIR.to_string(),
@@ -599,6 +625,7 @@ struct GuiConfigPresence {
api_keys: Option<Vec<GuiApiKeyInput>>,
api_access_remarks: Option<Vec<GuiApiAccessRemark>>,
management_secret_key: Option<String>,
close_behavior: Option<WindowsCloseBehavior>,
codex_session_repair_on_launch: Option<bool>,
usage_statistics_enabled: Option<bool>,
plugins_enabled: Option<bool>,
@@ -616,6 +643,7 @@ struct GuiSettings {
port: u16,
allow_lan: bool,
run_on_startup: bool,
close_behavior: WindowsCloseBehavior,
}
#[derive(Clone, Serialize)]
@@ -629,6 +657,8 @@ struct AgentConfigStatus {
executable_path: Option<String>,
launch_targets: Vec<AgentLaunchTarget>,
version: Option<String>,
cli_version: Option<String>,
app_version: Option<String>,
config_paths: Vec<String>,
config_exists: bool,
config_valid: bool,
@@ -1348,6 +1378,16 @@ impl GuiConfigState {
})
}
fn set_close_behavior(
&self,
close_behavior: WindowsCloseBehavior,
) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.close_behavior = close_behavior;
Ok(())
})
}
fn set_codex_session_repair_on_launch(&self, enabled: bool) -> Result<GuiConfigFile, String> {
self.update(|config| {
config.codex_session_repair_on_launch = enabled;
@@ -1450,6 +1490,7 @@ impl From<&GuiConfigFile> for GuiSettings {
port: config.port,
allow_lan: config.allow_lan,
run_on_startup: config.run_on_startup,
close_behavior: config.close_behavior,
}
}
}
@@ -1632,8 +1673,18 @@ fn set_app_locale(
#[tauri::command]
fn resolve_windows_close_request(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
action: WindowsCloseAction,
remember: Option<bool>,
) -> Result<(), String> {
if remember.unwrap_or(false) {
let close_behavior = match action {
WindowsCloseAction::Exit => WindowsCloseBehavior::Exit,
WindowsCloseAction::MinimizeToTray => WindowsCloseBehavior::MinimizeToTray,
};
gui_config_state.set_close_behavior(close_behavior)?;
}
match action {
WindowsCloseAction::Exit => app.exit(0),
WindowsCloseAction::MinimizeToTray => {
@@ -1649,6 +1700,15 @@ fn resolve_windows_close_request(
Ok(())
}
#[tauri::command]
fn save_app_close_behavior(
gui_config_state: tauri::State<'_, GuiConfigState>,
behavior: WindowsCloseBehavior,
) -> Result<GuiSettings, String> {
let config = gui_config_state.set_close_behavior(behavior)?;
Ok(GuiSettings::from(&config))
}
fn inspect_agent_config_statuses(
app: &tauri::AppHandle,
config: &GuiConfigFile,
@@ -3250,6 +3310,7 @@ fn inspect_pi_provider_status(home: &Path, port: u16, api_key: &str) -> AgentCon
"unconfigured"
};
let error = errors.into_iter().next();
let cli_version = executable.as_deref().and_then(read_agent_version);
AgentConfigStatus {
id: PI_AGENT_ID.to_string(),
name: PI_AGENT_NAME.to_string(),
@@ -3258,7 +3319,9 @@ fn inspect_pi_provider_status(home: &Path, port: u16, api_key: &str) -> AgentCon
plugin_installed,
executable_path: launch_targets.first().map(|target| target.detail.clone()),
launch_targets,
version: executable.as_deref().and_then(read_agent_version),
version: cli_version.clone(),
cli_version,
app_version: None,
config_paths: vec![path_to_string(&config_path), path_to_string(&settings_path)],
config_exists,
config_valid,
@@ -3634,18 +3697,20 @@ fn inspect_agent_config(
};
let launch_targets = agent_launch_targets(client, home);
let executable = find_agent_executable(client, home);
let version = if client == AgentClient::ClaudeDesktop {
read_claude_desktop_version(home)
} else {
executable.as_deref().and_then(read_agent_version)
let cli_version = executable.as_deref().and_then(read_agent_version);
let app_version = match client {
AgentClient::ClaudeDesktop => read_claude_desktop_version(home),
AgentClient::Codex => read_codex_app_version(home),
_ => None,
};
let installed = version.is_some();
let version = cli_version.clone().or_else(|| app_version.clone());
let installed = agent_client_is_installed(client, &launch_targets, version.as_deref());
let mut warnings = Vec::new();
if !client.supported_platform() {
warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string());
} else if launch_targets.is_empty() && config_exists {
warnings.push("只检测到配置文件,未在 PATH 中找到客户端命令".to_string());
} else if !launch_targets.is_empty() && version.is_none() {
} else if !installed && !launch_targets.is_empty() && version.is_none() {
warnings.push("检测到客户端入口,但无法读取有效版本,未标记为已安装".to_string());
}
if let Some(message) = error.as_ref() {
@@ -3663,6 +3728,8 @@ fn inspect_agent_config(
executable_path: launch_targets.first().map(|target| target.detail.clone()),
launch_targets,
version,
cli_version,
app_version,
config_paths: paths.iter().map(|path| path_to_string(path)).collect(),
config_exists,
config_valid,
@@ -3896,6 +3963,22 @@ fn is_managed_agent_base_url(value: &str) -> bool {
value.starts_with("http://127.0.0.1:") || value.starts_with("http://localhost:")
}
fn agent_client_is_installed(
client: AgentClient,
launch_targets: &[AgentLaunchTarget],
version: Option<&str>,
) -> bool {
if client == AgentClient::Codex {
// Codex can be installed as either a CLI or a desktop app. Discovery
// of either real entry is enough; version lookup is independent.
launch_targets
.iter()
.any(|target| target.id == "cli" || target.id == "app")
} else {
version.is_some()
}
}
fn agent_launch_targets(client: AgentClient, home: &Path) -> Vec<AgentLaunchTarget> {
let mut targets = Vec::new();
if client == AgentClient::ClaudeDesktop {
@@ -3916,12 +3999,6 @@ fn agent_launch_targets(client: AgentClient, home: &Path) -> Vec<AgentLaunchTarg
label: "ChatGPT App".to_string(),
detail: codex_app_target_detail(&target),
});
} else if let Some(executable) = find_agent_executable(AgentClient::Codex, home) {
targets.push(AgentLaunchTarget {
id: "app".to_string(),
label: "ChatGPT App".to_string(),
detail: format!("{} app", path_to_string(&executable)),
});
}
}
@@ -4033,6 +4110,49 @@ fn read_claude_desktop_version(home: &Path) -> Option<String> {
find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path))
}
fn read_codex_app_version(home: &Path) -> Option<String> {
#[cfg(target_os = "windows")]
{
match find_codex_app_target(home)? {
CodexAppTarget::WindowsAppId(_) => read_windows_codex_store_version(),
CodexAppTarget::Application(path) => {
read_windows_executable_version(&path).or_else(|| read_agent_version(&path))
}
}
}
#[cfg(target_os = "macos")]
{
let CodexAppTarget::Application(path) = find_codex_app_target(home)?;
read_macos_app_version(&path)
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let _ = home;
None
}
}
#[cfg(target_os = "macos")]
fn read_macos_app_version(application: &Path) -> Option<String> {
let info_plist = application.join("Contents/Info.plist");
for key in ["CFBundleShortVersionString", "CFBundleVersion"] {
let output = Command::new("/usr/bin/plutil")
.args(["-extract", key, "raw", "-o", "-"])
.arg(&info_plist)
.output()
.ok()?;
if !output.status.success() {
continue;
}
if let Some(version) =
normalize_detected_agent_version(&String::from_utf8_lossy(&output.stdout))
{
return Some(version);
}
}
None
}
#[cfg(target_os = "windows")]
fn windows_system_root() -> PathBuf {
env::var_os("SystemRoot")
@@ -4181,6 +4301,42 @@ if ($package -and $package.Version) {
parse_windows_claude_desktop_version_output(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(target_os = "windows")]
fn read_windows_codex_store_version() -> Option<String> {
const VERSION_SCRIPT: &str = r#"
$ErrorActionPreference = 'SilentlyContinue'
$ProgressPreference = 'SilentlyContinue'
$package = @(Get-AppxPackage) |
Where-Object {
$_.Name -in @('OpenAI.Codex', 'OpenAI.CodexBeta', 'OpenAI.ChatGPT') -or
$_.PackageFamilyName -match '^OpenAI\.(Codex|CodexBeta|ChatGPT)_'
} |
Sort-Object { [version]$_.Version } -Descending |
Select-Object -First 1
if ($package -and $package.Version) {
Write-Output "VERSION:$($package.Version)"
}
"#;
let encoded_command = windows_powershell_encoded_command(VERSION_SCRIPT);
let mut command = Command::new(windows_powershell_executable());
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded_command,
]);
configure_background_command(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
parse_windows_codex_version_output(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(target_os = "windows")]
fn find_windows_registered_codex_app_target() -> Option<CodexAppTarget> {
const DISCOVERY_SCRIPT: &str = r#"
@@ -4284,6 +4440,43 @@ fn windows_powershell_encoded_command(script: &str) -> String {
STANDARD.encode(bytes)
}
#[cfg(target_os = "windows")]
fn windows_powershell_single_quoted_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
#[cfg(target_os = "windows")]
fn read_windows_executable_version(path: &Path) -> Option<String> {
let path = windows_powershell_single_quoted_literal(&path_to_string(path));
let script = format!(
r#"
$ErrorActionPreference = 'SilentlyContinue'
$item = Get-Item -LiteralPath {path}
$version = $item.VersionInfo.ProductVersion
if ($version) {{
Write-Output "VERSION:$version"
}}
"#
);
let encoded_command = windows_powershell_encoded_command(&script);
let mut command = Command::new(windows_powershell_executable());
command.args([
"-NoLogo",
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
&encoded_command,
]);
configure_background_command(&mut command);
let output = command.output().ok()?;
if !output.status.success() {
return None;
}
parse_windows_codex_version_output(&String::from_utf8_lossy(&output.stdout))
}
#[cfg(target_os = "windows")]
fn parse_windows_codex_app_discovery_output(output: &str) -> Option<CodexAppTarget> {
output.lines().find_map(|line| {
@@ -4315,6 +4508,15 @@ fn parse_windows_claude_desktop_version_output(output: &str) -> Option<String> {
})
}
#[cfg(target_os = "windows")]
fn parse_windows_codex_version_output(output: &str) -> Option<String> {
output.lines().find_map(|line| {
line.trim()
.strip_prefix("VERSION:")
.and_then(normalize_detected_agent_version)
})
}
#[cfg(target_os = "windows")]
fn find_windows_codex_app_id_via_registry() -> Option<String> {
const PACKAGES_KEY: &str = r"HKCU\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages";
@@ -15243,6 +15445,9 @@ fn load_or_create_gui_config() -> Result<GuiConfigFile, String> {
if presence.locale.is_none() {
changed = true;
}
if presence.close_behavior.is_none() {
changed = true;
}
if presence.codex_session_repair_on_launch.is_none() {
changed = true;
}
@@ -15579,6 +15784,7 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul
("allow-lan", value(config.allow_lan)),
("host", value(config.host.as_str())),
("run-on-startup", value(config.run_on_startup)),
("close-behavior", value(config.close_behavior.as_str())),
("auth-dir", value(config.auth_dir.as_str())),
(
"management-secret-key",
@@ -18068,6 +18274,7 @@ fn main() {
save_api_access_remark,
set_app_locale,
resolve_windows_close_request,
save_app_close_behavior,
get_agent_config_statuses,
refresh_agent_config_statuses,
get_agent_models,
@@ -19583,6 +19790,7 @@ model:
assert!(content.contains("port = 8317"));
assert!(content.contains("allow-lan = false"));
assert!(content.contains("run-on-startup = false"));
assert!(content.contains("close-behavior = \"ask\""));
assert!(content.contains("window-width = 1531"));
assert!(content.contains("window-height = 891"));
assert!(content.contains("auth-dir = \"../oauth\""));
@@ -20762,6 +20970,32 @@ oauth-model-alias:
assert_eq!(GuiConfigFile::default().locale, "zh-CN");
}
#[test]
fn codex_installation_is_detected_from_either_cli_or_app_entry() {
let cli_target = vec![AgentLaunchTarget {
id: "cli".to_string(),
label: "Codex CLI".to_string(),
detail: "codex".to_string(),
}];
let app_target = vec![AgentLaunchTarget {
id: "app".to_string(),
label: "ChatGPT App".to_string(),
detail: "Microsoft Store · OpenAI.Codex_2p2nqsd0c76g0!App".to_string(),
}];
assert!(agent_client_is_installed(
AgentClient::Codex,
&cli_target,
None
));
assert!(agent_client_is_installed(
AgentClient::Codex,
&app_target,
None
));
assert!(!agent_client_is_installed(AgentClient::Codex, &[], None));
}
#[cfg(target_os = "windows")]
#[test]
fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() {
@@ -20829,6 +21063,12 @@ oauth-model-alias:
);
assert!(parse_windows_claude_desktop_version_output("VERSION:\r\n").is_none());
assert!(parse_windows_claude_desktop_version_output("VERSION:unknown\r\n").is_none());
assert_eq!(
parse_windows_codex_version_output("VERSION:26.727.6591.0\r\n").as_deref(),
Some("26.727.6591.0")
);
assert!(parse_windows_codex_version_output("VERSION:\r\n").is_none());
assert!(parse_windows_codex_version_output("VERSION:unknown\r\n").is_none());
}
#[test]
@@ -22260,6 +22500,7 @@ custom_option = "keep-original"
allow_lan: true,
host: "0.0.0.0".to_string(),
run_on_startup: false,
close_behavior: WindowsCloseBehavior::Ask,
window_width: None,
window_height: None,
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
+1
View File
@@ -2193,6 +2193,7 @@ mod tests {
allow_lan: false,
host: "127.0.0.1".to_string(),
run_on_startup: false,
close_behavior: crate::WindowsCloseBehavior::Ask,
window_width: None,
window_height: None,
auth_dir: String::new(),
+46 -4
View File
@@ -80,12 +80,18 @@ const pages = [
type PageId = (typeof pages)[number]['id'];
type WindowsCloseAction = 'exit' | 'minimize-to-tray';
type WindowsCloseBehavior = 'ask' | WindowsCloseAction;
type WindowsClosePrompt = {
resolvingAction: WindowsCloseAction | null;
rememberChoice: boolean;
error: string | null;
};
type GuiSettings = {
closeBehavior: WindowsCloseBehavior;
};
function HomePage() {
return <KernelPage view="home" />;
}
@@ -155,13 +161,28 @@ function AppContent() {
let disposed = false;
let stopListening: (() => void) | undefined;
void listen('windows-close-requested', () => {
const handleWindowsCloseRequest = async () => {
try {
const settings = await invoke<GuiSettings>('get_gui_settings');
if (settings.closeBehavior !== 'ask') {
await resolveWindowsCloseRequest(settings.closeBehavior, false);
return;
}
} catch (error) {
console.error('读取关闭行为设置失败', error);
}
setWindowsClosePrompt((current) =>
current ?? {
resolvingAction: null,
rememberChoice: false,
error: null,
},
);
};
void listen('windows-close-requested', () => {
void handleWindowsCloseRequest();
})
.then((stop) => {
if (disposed) {
@@ -206,7 +227,10 @@ function AppContent() {
}
};
const resolveWindowsCloseRequest = async (action: WindowsCloseAction) => {
const resolveWindowsCloseRequest = async (
action: WindowsCloseAction,
remember = windowsClosePrompt?.rememberChoice ?? false,
) => {
setWindowsClosePrompt((current) =>
current
? {
@@ -218,7 +242,7 @@ function AppContent() {
);
try {
await invoke('resolve_windows_close_request', { action });
await invoke('resolve_windows_close_request', { action, remember });
setWindowsClosePrompt(null);
} catch (error) {
setWindowsClosePrompt((current) =>
@@ -228,7 +252,11 @@ function AppContent() {
resolvingAction: null,
error: error instanceof Error ? error.message : String(error),
}
: current,
: {
resolvingAction: null,
rememberChoice: false,
error: error instanceof Error ? error.message : String(error),
},
);
}
};
@@ -415,6 +443,20 @@ function AppContent() {
{windowsClosePrompt.error}
</div>
) : null}
<label className="close-dialog-remember">
<input
type="checkbox"
checked={windowsClosePrompt.rememberChoice}
disabled={windowsClosePrompt.resolvingAction !== null}
onChange={(event) => {
const rememberChoice = event.currentTarget.checked;
setWindowsClosePrompt((current) =>
current ? { ...current, rememberChoice } : current,
);
}}
/>
<span>{t('app.close.remember')}</span>
</label>
<div className="close-dialog-actions">
<button
type="button"
+12
View File
@@ -29,6 +29,7 @@ export const jaOverrides = {
'app.close.minimize': 'トレイに最小化',
'app.close.exiting': '終了しています...',
'app.close.exit': 'アプリを終了',
'app.close.remember': 'この選択を記憶',
'error.render.title': 'ページの描画中にエラーが発生しました',
'error.unknown': '不明なエラー',
'error.reload': 'ページを再読み込み',
@@ -261,6 +262,7 @@ export const jaOverrides = {
'config.tabs.label': '詳細設定ページ',
'config.tabs.general': '基本設定',
'config.tabs.network': 'ネットワークとルーティング',
'config.tabs.software': 'ソフトウェア設定',
'config.error.emptyKey': '認証キーを入力してください',
'config.error.invalidKey': '空白を含まない表示可能な ASCII 文字のみ使用できます',
'config.error.duplicateKey': '同じ認証キーがすでに存在します',
@@ -337,6 +339,14 @@ export const jaOverrides = {
'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します',
'config.network.sessionTtl': 'セッションアフィニティ TTL',
'config.network.sessionTtlHint': '例:1h。空欄の場合はコアのデフォルト値を使用します',
'config.notice.softwareCloseBehaviorUpdated': 'アプリの終了動作を更新しました',
'config.software.title': 'ソフトウェア設定',
'config.software.description': 'ウィンドウの閉じるボタンをクリックしたときのデフォルト動作を設定します。「毎回確認」を選ぶと確認ダイアログに戻ります。',
'config.software.closeBehavior': '終了動作',
'config.software.closeBehaviorHint': '選択を記憶すると、ウィンドウを閉じたときに選択した動作をすぐに実行します。',
'config.software.behavior.ask': '毎回確認',
'config.software.behavior.minimize': 'トレイに最小化',
'config.software.behavior.exit': 'アプリを終了',
'config.keys.addTitle': '認証キーを追加',
'config.keys.editTitle': '認証キーを編集',
'common.close': '閉じる',
@@ -546,6 +556,8 @@ export const jaOverrides = {
'agents.clientDetected': 'クライアントを検出しました',
'agents.clientNotDetected': 'クライアントを検出できません',
'agents.clientVersion': 'クライアントバージョン',
'agents.cliVersion': 'CLI バージョン',
'agents.appVersion': 'App バージョン',
'agents.notFetched': '未取得',
'agents.useModel': '使用モデル',
'agents.pending': '未適用',
+24
View File
@@ -32,6 +32,7 @@ export const zhCN = {
'app.close.minimize': '最小化到托盘',
'app.close.exiting': '正在退出...',
'app.close.exit': '退出程序',
'app.close.remember': '记住选项',
'error.render.title': '页面渲染出现异常',
'error.unknown': '未知错误',
'error.reload': '重新加载页面',
@@ -264,6 +265,7 @@ export const zhCN = {
'config.tabs.label': '高级设置子页面',
'config.tabs.general': '基础设置',
'config.tabs.network': '网络与路由',
'config.tabs.software': '软件设置',
'config.error.emptyKey': '鉴权密钥不能为空',
'config.error.invalidKey': '只能使用 ASCII 可见字符,且不能包含空格',
'config.error.duplicateKey': '该鉴权密钥已经存在',
@@ -284,6 +286,14 @@ export const zhCN = {
'config.notice.sessionAffinityEnabled': '会话粘性路由已启用',
'config.notice.sessionAffinityDisabled': '会话粘性路由已停用',
'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存',
'config.notice.softwareCloseBehaviorUpdated': '软件关闭行为已更新',
'config.software.title': '软件设置',
'config.software.description': '设置点击窗口关闭按钮时的默认行为。选择“每次询问”即可恢复关闭确认弹窗。',
'config.software.closeBehavior': '关闭行为',
'config.software.closeBehaviorHint': '记住选项后,关闭窗口将直接执行所选操作。',
'config.software.behavior.ask': '每次询问',
'config.software.behavior.minimize': '最小化到托盘',
'config.software.behavior.exit': '退出程序',
'config.webuiKey.title': 'WebUI 密钥',
'config.webuiKey.configured': '已配置',
'config.webuiKey.unconfigured': '未配置',
@@ -549,6 +559,8 @@ export const zhCN = {
'agents.clientDetected': '已检测到客户端',
'agents.clientNotDetected': '未检测到客户端',
'agents.clientVersion': '客户端版本',
'agents.cliVersion': 'CLI 版本',
'agents.appVersion': 'App 版本',
'agents.notFetched': '未获取',
'agents.useModel': '使用模型',
'agents.pending': '尚未应用',
@@ -973,6 +985,7 @@ export const en: Record<MessageKey, string> = {
'app.close.minimize': 'Minimize to Tray',
'app.close.exiting': 'Exiting...',
'app.close.exit': 'Exit App',
'app.close.remember': 'Remember this choice',
'error.render.title': 'Something went wrong while rendering this page',
'error.unknown': 'Unknown error',
'error.reload': 'Reload Page',
@@ -1205,6 +1218,7 @@ export const en: Record<MessageKey, string> = {
'config.tabs.label': 'Advanced settings sections',
'config.tabs.general': 'General Settings',
'config.tabs.network': 'Network & 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',
@@ -1225,6 +1239,14 @@ export const en: Record<MessageKey, string> = {
'config.notice.sessionAffinityEnabled': 'Session affinity enabled',
'config.notice.sessionAffinityDisabled': 'Session affinity disabled',
'config.notice.sessionTtlUpdated': 'Session affinity TTL saved',
'config.notice.softwareCloseBehaviorUpdated': 'App close behavior updated',
'config.software.title': 'Software Settings',
'config.software.description': 'Choose what happens by default when you click the window close button. Select “Ask every time” to restore the confirmation dialog.',
'config.software.closeBehavior': 'Close behavior',
'config.software.closeBehaviorHint': 'When a choice is remembered, closing the window performs the selected action immediately.',
'config.software.behavior.ask': 'Ask every time',
'config.software.behavior.minimize': 'Minimize to Tray',
'config.software.behavior.exit': 'Exit App',
'config.webuiKey.title': 'WebUI Key',
'config.webuiKey.configured': 'Configured',
'config.webuiKey.unconfigured': 'Not configured',
@@ -1490,6 +1512,8 @@ export const en: Record<MessageKey, string> = {
'agents.clientDetected': 'Client detected',
'agents.clientNotDetected': 'Client not detected',
'agents.clientVersion': 'Client Version',
'agents.cliVersion': 'CLI Version',
'agents.appVersion': 'App Version',
'agents.notFetched': 'Not available',
'agents.useModel': 'Model',
'agents.pending': 'Not Applied',
+20 -5
View File
@@ -66,6 +66,8 @@ type AgentConfigStatus = {
executablePath: string | null;
launchTargets: AgentLaunchTarget[];
version: string | null;
cliVersion: string | null;
appVersion: string | null;
configValid: boolean;
configured: boolean;
currentModel: string | null;
@@ -1259,15 +1261,28 @@ export function AgentsPage() {
role="tabpanel"
aria-labelledby="agent-subpage-tab-core"
>
<div className="agent-status-grid">
<div className={`agent-status-grid ${selected === 'codex' ? 'codex-status-grid' : ''}`}>
<div>
<span><BadgeCheck size={14} />{t('agents.installStatus')}</span>
<strong>{activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')}</strong>
</div>
<div>
<span>{t('agents.clientVersion')}</span>
<strong title={activeStatus?.version ?? undefined}>{activeStatus?.version ?? t('agents.notFetched')}</strong>
</div>
{selected === 'codex' ? (
<>
<div>
<span>{t('agents.cliVersion')}</span>
<strong title={activeStatus?.cliVersion ?? undefined}>{activeStatus?.cliVersion ?? t('agents.notFetched')}</strong>
</div>
<div>
<span>{t('agents.appVersion')}</span>
<strong title={activeStatus?.appVersion ?? undefined}>{activeStatus?.appVersion ?? t('agents.notFetched')}</strong>
</div>
</>
) : (
<div>
<span>{t('agents.clientVersion')}</span>
<strong title={activeStatus?.version ?? undefined}>{activeStatus?.version ?? t('agents.notFetched')}</strong>
</div>
)}
</div>
{activeStatus?.error || (!isPiClient && activeStatus?.warnings.length) ? (
+120 -2
View File
@@ -15,6 +15,7 @@ import {
Plus,
RefreshCw,
Route,
Settings2,
ShieldCheck,
Sparkles,
Trash2,
@@ -48,14 +49,20 @@ type ConfigAction =
| 'management-secret'
| 'routing'
| 'network'
| 'software'
| null;
type NoticeTone = 'success' | 'error';
type ConfigSubpage = 'general' | 'network' | 'aliases';
type ConfigSubpage = 'general' | 'network' | 'software' | 'aliases';
type CloseBehavior = 'ask' | 'exit' | 'minimize-to-tray';
type NetworkDraftField = 'port' | 'allowLan' | 'proxyUrl' | 'sessionAffinity' | 'sessionTtl';
type DraftRefreshMode = 'replace' | 'preserve';
type NetworkDraftDirty = Record<NetworkDraftField, boolean>;
type GuiSettings = {
closeBehavior: CloseBehavior;
};
const cleanNetworkDraft = (): NetworkDraftDirty => ({
port: false,
allowLan: false,
@@ -73,6 +80,9 @@ export function ConfigPanelPage() {
const { t } = useI18n();
const { status: coreStatus, publishStatus, refreshStatus } = useCoreRuntime();
const [settings, setSettings] = useState<CoreConfigSettings | null>(null);
const [softwareSettings, setSoftwareSettings] = useState<GuiSettings | null>(null);
const [softwareSettingsLoading, setSoftwareSettingsLoading] = useState(true);
const [softwareCloseBehaviorDraft, setSoftwareCloseBehaviorDraft] = useState<CloseBehavior>('ask');
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [busyAction, setBusyAction] = useState<ConfigAction>(null);
@@ -105,8 +115,12 @@ export function ConfigPanelPage() {
let disposed = false;
let stop: (() => void) | null = null;
void loadSettings();
void loadSoftwareSettings();
void listen('config-files-changed', () => {
if (!disposed) void loadSettings('preserve');
if (!disposed) {
void loadSettings('preserve');
void loadSoftwareSettings();
}
}).then((unlisten) => {
if (disposed) unlisten();
else stop = unlisten;
@@ -177,6 +191,20 @@ export function ConfigPanelPage() {
}
}
async function loadSoftwareSettings() {
setSoftwareSettingsLoading(true);
try {
const result = await invoke<GuiSettings>('get_gui_settings');
setSoftwareSettings(result);
setSoftwareCloseBehaviorDraft(result.closeBehavior);
} catch (error) {
setSoftwareSettings(null);
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
} finally {
setSoftwareSettingsLoading(false);
}
}
const runMutation = async (
action: Exclude<ConfigAction, null>,
command: string,
@@ -431,6 +459,26 @@ export function ConfigPanelPage() {
}
};
const saveSoftwareCloseBehavior = async () => {
if (!softwareSettings || busyAction !== null) return;
if (softwareCloseBehaviorDraft === softwareSettings.closeBehavior) return;
setBusyAction('software');
try {
const result = await invoke<GuiSettings>('save_app_close_behavior', {
behavior: softwareCloseBehaviorDraft,
});
setSoftwareSettings(result);
setSoftwareCloseBehaviorDraft(result.closeBehavior);
showNotice(t('config.notice.softwareCloseBehaviorUpdated'), 'success');
} catch (error) {
setSoftwareCloseBehaviorDraft(softwareSettings.closeBehavior);
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
} finally {
setBusyAction(null);
}
};
const controlsDisabled = loading || settings === null || busyAction !== null;
const networkRoutingDirty = Boolean(settings) && (
portDraft !== String(settings?.port)
@@ -450,6 +498,8 @@ export function ConfigPanelPage() {
: savedStatusVisible
? t('config.network.saved')
: '';
const softwareCloseBehaviorDirty = softwareSettings !== null
&& softwareCloseBehaviorDraft !== softwareSettings.closeBehavior;
const networkStatusIsSaved = !loading
&& settings !== null
&& busyAction === null
@@ -488,6 +538,18 @@ export function ConfigPanelPage() {
>
{t('config.tabs.network')}
</button>
<button
type="button"
id="config-subpage-tab-software"
role="tab"
className={activeSubpage === 'software' ? 'active' : ''}
aria-selected={activeSubpage === 'software'}
aria-controls="config-subpage-panel-software"
tabIndex={activeSubpage === 'software' ? 0 : -1}
onClick={() => setActiveSubpage('software')}
>
{t('config.tabs.software')}
</button>
<button
type="button"
id="config-subpage-tab-aliases"
@@ -932,6 +994,62 @@ export function ConfigPanelPage() {
</div>
</section>
</div>
) : activeSubpage === 'software' ? (
<div
className="config-subpage-panel"
id="config-subpage-panel-software"
role="tabpanel"
aria-labelledby="config-subpage-tab-software"
>
<section className="panel config-software-panel">
<div className="config-panel-heading">
<div className="config-heading-title">
<Settings2 size={18} aria-hidden="true" />
<h2>{t('config.software.title')}</h2>
</div>
<div className="config-heading-actions">
<span className={`state-pill ${softwareSettings && !softwareCloseBehaviorDirty ? 'success' : ''}`}>
{softwareSettingsLoading
? t('common.loading')
: softwareSettings === null
? t('common.unavailable')
: softwareCloseBehaviorDirty
? t('config.network.unsaved')
: t('config.network.saved')}
</span>
<button
type="button"
className="primary-button compact-button"
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null || !softwareCloseBehaviorDirty}
onClick={() => void saveSoftwareCloseBehavior()}
>
<Check size={16} aria-hidden="true" />
{busyAction === 'software' ? t('common.saving') : t('config.network.confirmSave')}
</button>
</div>
</div>
<div className="config-software-content">
<div className="config-management-description">
<strong>{t('config.software.closeBehavior')}</strong>
<p>{t('config.software.description')}</p>
<small>{t('config.software.closeBehaviorHint')}</small>
</div>
<label className="config-software-field">
<span>{t('config.software.closeBehavior')}</span>
<select
className="config-network-input"
value={softwareCloseBehaviorDraft}
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null}
onChange={(event) => setSoftwareCloseBehaviorDraft(event.currentTarget.value as CloseBehavior)}
>
<option value="ask">{t('config.software.behavior.ask')}</option>
<option value="minimize-to-tray">{t('config.software.behavior.minimize')}</option>
<option value="exit">{t('config.software.behavior.exit')}</option>
</select>
</label>
</div>
</section>
</div>
) : (
<div
className="config-subpage-panel"
+60
View File
@@ -2401,6 +2401,23 @@ textarea:focus-visible {
overflow-wrap: anywhere;
}
.close-dialog-remember {
display: inline-flex;
align-items: center;
justify-self: start;
gap: 8px;
color: var(--theme-5f5a53);
font-size: var(--font-size-label);
cursor: pointer;
}
.close-dialog-remember input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--theme-4f825c);
}
.close-dialog-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2423,6 +2440,35 @@ textarea:focus-visible {
outline-offset: 2px;
}
.config-software-panel {
display: grid;
gap: 18px;
}
.config-software-content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 320px);
align-items: start;
gap: 24px;
padding: 16px;
border: 1px solid var(--theme-d5d2cb);
border-radius: 8px;
background: var(--theme-fffdf8);
}
.config-software-field {
display: grid;
min-width: 0;
gap: 8px;
color: var(--theme-39352f);
font-size: var(--font-size-body);
font-weight: 700;
}
.config-software-field .config-network-input {
height: 42px;
}
@media (max-width: 520px) {
.close-dialog-backdrop {
padding: 16px;
@@ -2437,6 +2483,12 @@ textarea:focus-visible {
}
}
@media (max-width: 700px) {
.config-software-content {
grid-template-columns: minmax(0, 1fr);
}
}
.config-delete-dialog {
grid-template-rows: 40px 52px 40px;
height: 202px;
@@ -5253,6 +5305,10 @@ textarea:focus-visible {
min-height: 76px;
}
.agent-status-grid.codex-status-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.agent-status-grid > div {
display: grid;
min-width: 0;
@@ -7230,6 +7286,10 @@ textarea:focus-visible {
min-height: 0;
}
.agent-status-grid.codex-status-grid {
grid-template-columns: 1fr;
}
.codex-session-metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}