feat: add remembered app close behavior settings

This commit is contained in:
lzt404 committed 2026-08-03 16:34:31 +08:00
1 parent de4227421e
commit 748996036e
7 files changed
+314 -6

No files matched your search

+65
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)]
@@ -1348,6 +1376,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 +1488,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 +1671,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 +1698,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,
@@ -15243,6 +15301,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 +15640,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 +18130,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 +19646,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\""));
@@ -22260,6 +22324,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"
+10
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': '閉じる',
+20
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': '未配置',
@@ -973,6 +983,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 +1216,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 +1237,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',
+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"
+52
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;