feat(settings): add system autostart support

- integrate Tauri autostart management with rollback on save failures
- redesign the software settings panel for startup and close behavior
- add localized labels and responsive styling
This commit is contained in:
lzt404 committed 2026-08-08 16:06:53 +08:00
1 parent d2f238f829
commit d05d12308b
8 files changed
+361 -77

No files matched your search

+73 -7
View File
@@ -228,6 +228,17 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "auto-launch"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471"
dependencies = [
"dirs 4.0.0",
"thiserror 1.0.69",
"winreg 0.10.1",
]
[[package]]
name = "autocfg"
version = "1.5.1"
@@ -605,6 +616,7 @@ dependencies = [
"tar",
"tauri",
"tauri-build",
"tauri-plugin-autostart",
"tauri-plugin-opener",
"tokio",
"tokio-util",
@@ -803,13 +815,33 @@ dependencies = [
"crypto-common",
]
[[package]]
name = "dirs"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
dependencies = [
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
"dirs-sys",
"dirs-sys 0.5.0",
]
[[package]]
name = "dirs-sys"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
dependencies = [
"libc",
"redox_users 0.4.6",
"winapi",
]
[[package]]
@@ -820,7 +852,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
"redox_users",
"redox_users 0.5.2",
"windows-sys 0.61.2",
]
@@ -947,7 +979,7 @@ dependencies = [
"rustc_version",
"toml 1.1.3+spec-1.1.0",
"vswhom",
"winreg",
"winreg 0.55.0",
]
[[package]]
@@ -3033,6 +3065,17 @@ dependencies = [
"bitflags 2.13.0",
]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]]
name = "redox_users"
version = "0.5.2"
@@ -3884,7 +3927,7 @@ dependencies = [
"anyhow",
"bytes",
"cookie",
"dirs",
"dirs 6.0.0",
"dunce",
"embed_plist",
"getrandom 0.3.4",
@@ -3934,7 +3977,7 @@ checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632"
dependencies = [
"anyhow",
"cargo_toml",
"dirs",
"dirs 6.0.0",
"glob",
"heck 0.5.0",
"json-patch",
@@ -4004,6 +4047,20 @@ dependencies = [
"walkdir",
]
[[package]]
name = "tauri-plugin-autostart"
version = "2.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70"
dependencies = [
"auto-launch",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@@ -4501,7 +4558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65ba1e5f6b9ef9fd87e21b9c6f351554dbd717960089168fcfdef854686961dc"
dependencies = [
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"libappindicator",
"muda",
"objc2",
@@ -5323,6 +5380,15 @@ dependencies = [
"memchr",
]
[[package]]
name = "winreg"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.55.0"
@@ -5355,7 +5421,7 @@ dependencies = [
"block2",
"cookie",
"crossbeam-channel",
"dirs",
"dirs 6.0.0",
"dom_query",
"dpi",
"dunce",
+1
View File
@@ -27,6 +27,7 @@ serde_yaml = "0.9"
sha2 = "0.10"
tar = "0.4"
tauri = { version = "~2.11", features = ["tray-icon"] }
tauri-plugin-autostart = "2"
tauri-plugin-opener = "2"
tokio = { version = "1", features = ["macros", "sync", "time"] }
tokio-util = "0.7"
+91 -6
View File
@@ -41,6 +41,7 @@ use tauri::{
tray::{MouseButton, TrayIconBuilder, TrayIconEvent},
};
use tauri::{Emitter, LogicalSize, Manager};
use tauri_plugin_autostart::ManagerExt as AutostartManagerExt;
use tauri_plugin_opener::OpenerExt;
use tokio_util::sync::CancellationToken;
use zip::ZipArchive;
@@ -644,6 +645,20 @@ struct GuiSettings {
close_behavior: WindowsCloseBehavior,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SoftwareSettings {
close_behavior: WindowsCloseBehavior,
autostart_enabled: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SoftwareSettingsInput {
close_behavior: WindowsCloseBehavior,
autostart_enabled: bool,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConfigStatus {
@@ -1668,13 +1683,78 @@ fn resolve_windows_close_request(
Ok(())
}
fn app_autostart_enabled(app: &tauri::AppHandle) -> Result<bool, String> {
app.autolaunch()
.is_enabled()
.map_err(|error| format!("读取系统开机自启状态失败: {error}"))
}
fn set_app_autostart_enabled(app: &tauri::AppHandle, enabled: bool) -> Result<(), String> {
let manager = app.autolaunch();
if enabled {
manager
.enable()
.map_err(|error| format!("启用开机自启失败: {error}"))
} else {
manager
.disable()
.map_err(|error| format!("关闭开机自启失败: {error}"))
}
}
fn software_settings(
app: &tauri::AppHandle,
config: &GuiConfigFile,
) -> Result<SoftwareSettings, String> {
Ok(SoftwareSettings {
close_behavior: config.close_behavior,
autostart_enabled: app_autostart_enabled(app)?,
})
}
#[tauri::command]
fn save_app_close_behavior(
fn get_software_settings(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
behavior: WindowsCloseBehavior,
) -> Result<GuiSettings, String> {
let config = gui_config_state.set_close_behavior(behavior)?;
Ok(GuiSettings::from(&config))
) -> Result<SoftwareSettings, String> {
let config = gui_config_state.snapshot()?;
software_settings(&app, &config)
}
#[tauri::command]
fn save_software_settings(
app: tauri::AppHandle,
gui_config_state: tauri::State<'_, GuiConfigState>,
settings: SoftwareSettingsInput,
) -> Result<SoftwareSettings, String> {
let previous_config = gui_config_state.snapshot()?;
let previous_autostart_enabled = app_autostart_enabled(&app)?;
let autostart_changed = previous_autostart_enabled != settings.autostart_enabled;
if autostart_changed {
set_app_autostart_enabled(&app, settings.autostart_enabled)?;
}
let config = if previous_config.close_behavior == settings.close_behavior {
previous_config
} else {
match gui_config_state.set_close_behavior(settings.close_behavior) {
Ok(config) => config,
Err(error) => {
let rollback_error = autostart_changed
.then(|| set_app_autostart_enabled(&app, previous_autostart_enabled).err())
.flatten();
return Err(match rollback_error {
Some(rollback_error) => {
format!("{error}; 回滚开机自启设置也失败: {rollback_error}")
}
None => error,
});
}
}
};
software_settings(&app, &config)
}
fn inspect_agent_config_statuses(
@@ -18122,6 +18202,10 @@ fn main() {
let initial_window_size = configured_window_size(&gui_config);
let app = tauri::Builder::default()
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
.plugin(
tauri_plugin_opener::Builder::new()
.open_js_links_on_click(false)
@@ -18281,7 +18365,8 @@ fn main() {
save_api_access_remark,
set_app_locale,
resolve_windows_close_request,
save_app_close_behavior,
get_software_settings,
save_software_settings,
get_agent_config_statuses,
refresh_agent_config_statuses,
get_agent_models,
+4 -2
View File
@@ -345,10 +345,12 @@ export const jaOverrides = {
'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します',
'config.network.sessionTtl': 'セッションアフィニティ TTL',
'config.network.sessionTtlHint': '例:1h。空欄の場合はコアのデフォルト値を使用します',
'config.notice.softwareCloseBehaviorUpdated': 'アプリの終了動作を更新しました',
'config.notice.softwareUpdated': 'ソフトウェア設定を保存しました',
'config.software.title': 'ソフトウェア設定',
'config.software.description': 'ウィンドウの閉じるボタンをクリックしたときのデフォルト動作を設定します。「毎回確認」を選ぶと確認ダイアログに戻ります。',
'config.software.autostart': 'システム起動時に開始',
'config.software.autostartDescription': 'サインイン後に EasyCLIProxyAPI を自動的に起動します',
'config.software.closeBehavior': '終了動作',
'config.software.closeBehaviorDescription': 'ウィンドウを閉じたときにアプリを終了するか、システムトレイで実行を続けるかを選択します',
'config.software.behavior.ask': '毎回確認',
'config.software.behavior.minimize': 'トレイに最小化',
'config.software.behavior.exit': 'アプリを終了',
+8 -4
View File
@@ -292,10 +292,12 @@ export const zhCN = {
'config.notice.sessionAffinityEnabled': '会话粘性路由已启用',
'config.notice.sessionAffinityDisabled': '会话粘性路由已停用',
'config.notice.sessionTtlUpdated': '会话粘性 TTL 已保存',
'config.notice.softwareCloseBehaviorUpdated': '软件关闭行为已更新',
'config.notice.softwareUpdated': '软件设置已保存',
'config.software.title': '软件设置',
'config.software.description': '设置点击窗口关闭按钮时的默认行为。选择“每次询问”即可恢复关闭确认弹窗。',
'config.software.autostart': '开机自启',
'config.software.autostartDescription': '登录系统后自动启动 EasyCLIProxyAPI',
'config.software.closeBehavior': '关闭行为',
'config.software.closeBehaviorDescription': '决定关闭窗口时是退出程序还是继续在系统托盘运行',
'config.software.behavior.ask': '每次询问',
'config.software.behavior.minimize': '最小化到托盘',
'config.software.behavior.exit': '退出程序',
@@ -1221,10 +1223,12 @@ 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.notice.softwareUpdated': 'Software settings saved',
'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.autostart': 'Launch at startup',
'config.software.autostartDescription': 'Automatically launch EasyCLIProxyAPI when you sign in',
'config.software.closeBehavior': 'Close behavior',
'config.software.closeBehaviorDescription': 'Choose whether closing the window exits the app or keeps it running in the system tray',
'config.software.behavior.ask': 'Ask every time',
'config.software.behavior.minimize': 'Minimize to Tray',
'config.software.behavior.exit': 'Exit App',
+4
View File
@@ -1,4 +1,8 @@
const phraseReplacements: ReadonlyArray<readonly [string, string]> = [
['登录系统后自动启动', '登入系統後自動啟動'],
['系统托盘', '系統匣'],
['开机自启', '開機自啟'],
['行为', '行為'],
['兼容格式', '相容格式'],
['历史', '歷史'],
['高级设置', '進階設定'],
+80 -30
View File
@@ -59,8 +59,9 @@ type DraftRefreshMode = 'replace' | 'preserve';
type NetworkDraftDirty = Record<NetworkDraftField, boolean>;
type GuiSettings = {
type SoftwareSettings = {
closeBehavior: CloseBehavior;
autostartEnabled: boolean;
};
const cleanNetworkDraft = (): NetworkDraftDirty => ({
@@ -80,9 +81,10 @@ 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 [softwareSettings, setSoftwareSettings] = useState<SoftwareSettings | null>(null);
const [softwareSettingsLoading, setSoftwareSettingsLoading] = useState(true);
const [softwareCloseBehaviorDraft, setSoftwareCloseBehaviorDraft] = useState<CloseBehavior>('ask');
const [softwareAutostartDraft, setSoftwareAutostartDraft] = useState(false);
const [softwareSavedStatusVisible, setSoftwareSavedStatusVisible] = useState(false);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
@@ -196,9 +198,10 @@ export function ConfigPanelPage() {
setSoftwareSettingsLoading(true);
setSoftwareSavedStatusVisible(false);
try {
const result = await invoke<GuiSettings>('get_gui_settings');
const result = await invoke<SoftwareSettings>('get_software_settings');
setSoftwareSettings(result);
setSoftwareCloseBehaviorDraft(result.closeBehavior);
setSoftwareAutostartDraft(result.autostartEnabled);
} catch (error) {
setSoftwareSettings(null);
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
@@ -461,23 +464,32 @@ export function ConfigPanelPage() {
}
};
const saveSoftwareCloseBehavior = async () => {
const saveSoftwareSettings = async () => {
if (!softwareSettings || busyAction !== null) return;
if (softwareCloseBehaviorDraft === softwareSettings.closeBehavior) return;
if (
softwareCloseBehaviorDraft === softwareSettings.closeBehavior
&& softwareAutostartDraft === softwareSettings.autostartEnabled
) return;
setBusyAction('software');
try {
const result = await invoke<GuiSettings>('save_app_close_behavior', {
behavior: softwareCloseBehaviorDraft,
const result = await invoke<SoftwareSettings>('save_software_settings', {
settings: {
closeBehavior: softwareCloseBehaviorDraft,
autostartEnabled: softwareAutostartDraft,
},
});
setSoftwareSettings(result);
setSoftwareCloseBehaviorDraft(result.closeBehavior);
setSoftwareAutostartDraft(result.autostartEnabled);
setSoftwareSavedStatusVisible(true);
showNotice(t('config.notice.softwareCloseBehaviorUpdated'), 'success');
showNotice(t('config.notice.softwareUpdated'), 'success');
} catch (error) {
setSoftwareCloseBehaviorDraft(softwareSettings.closeBehavior);
setSoftwareAutostartDraft(softwareSettings.autostartEnabled);
setSoftwareSavedStatusVisible(false);
showNotice(t('config.error.saveFailed', { error: String(error) }), 'error');
void loadSoftwareSettings();
} finally {
setBusyAction(null);
}
@@ -504,18 +516,21 @@ export function ConfigPanelPage() {
: '';
const softwareCloseBehaviorDirty = softwareSettings !== null
&& softwareCloseBehaviorDraft !== softwareSettings.closeBehavior;
const softwareAutostartDirty = softwareSettings !== null
&& softwareAutostartDraft !== softwareSettings.autostartEnabled;
const softwareSettingsDirty = softwareCloseBehaviorDirty || softwareAutostartDirty;
const softwareStatusLabel = softwareSettingsLoading
? t('common.loading')
: softwareSettings === null
? t('common.unavailable')
: softwareCloseBehaviorDirty
: softwareSettingsDirty
? t('config.network.unsaved')
: softwareSavedStatusVisible
? t('config.network.saved')
: '';
const softwareStatusIsSaved = !softwareSettingsLoading
&& softwareSettings !== null
&& !softwareCloseBehaviorDirty
&& !softwareSettingsDirty
&& softwareSavedStatusVisible;
const networkStatusIsSaved = !loading
&& settings !== null
@@ -1033,8 +1048,8 @@ export function ConfigPanelPage() {
<button
type="button"
className="primary-button compact-button"
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null || !softwareCloseBehaviorDirty}
onClick={() => void saveSoftwareCloseBehavior()}
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null || !softwareSettingsDirty}
onClick={() => void saveSoftwareSettings()}
>
<Check size={16} aria-hidden="true" />
{busyAction === 'software' ? t('common.saving') : t('config.network.confirmSave')}
@@ -1042,25 +1057,60 @@ export function ConfigPanelPage() {
</div>
</div>
<div className="config-software-content">
<div className="config-software-description">
<p>{t('config.software.description')}</p>
<div className="config-software-settings-list">
<div className="config-software-setting-row">
<div className="config-software-setting-copy">
<span className="config-software-setting-icon" aria-hidden="true">
<Clock3 size={18} />
</span>
<div>
<strong>{t('config.software.autostart')}</strong>
<small>{t('config.software.autostartDescription')}</small>
</div>
</div>
<label className="switch-control" title={t('config.software.autostart')}>
<input
type="checkbox"
role="switch"
aria-label={t('config.software.autostart')}
checked={softwareAutostartDraft}
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null}
onChange={(event) => {
setSoftwareSavedStatusVisible(false);
setSoftwareAutostartDraft(event.currentTarget.checked);
}}
/>
<span className="switch-track" />
</label>
</div>
<div className="config-software-setting-row config-software-close-row">
<div className="config-software-setting-copy">
<span className="config-software-setting-icon" aria-hidden="true">
<X size={18} />
</span>
<div>
<strong>{t('config.software.closeBehavior')}</strong>
<small>{t('config.software.closeBehaviorDescription')}</small>
</div>
</div>
<label className="config-software-select">
<span className="sr-only">{t('config.software.closeBehavior')}</span>
<select
className="config-network-input"
value={softwareCloseBehaviorDraft}
disabled={softwareSettingsLoading || softwareSettings === null || busyAction !== null}
onChange={(event) => {
setSoftwareSavedStatusVisible(false);
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>
</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) => {
setSoftwareSavedStatusVisible(false);
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>
+100 -28
View File
@@ -2449,45 +2449,87 @@ textarea:focus-visible {
gap: 18px;
}
.config-software-panel > .config-panel-heading {
width: min(960px, 100%);
margin: 0 auto;
}
.config-software-content {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px);
align-items: stretch;
gap: 16px;
width: min(960px, 100%);
margin: 0 auto;
}
.config-software-description,
.config-software-field {
min-width: 0;
min-height: 112px;
padding: 16px;
.config-software-setting-icon {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
border: 1px solid var(--theme-e3e1db);
border-radius: 8px;
background: var(--theme-faf8f2);
background: var(--theme-fffdf8);
color: var(--theme-777067);
}
.config-software-description {
display: flex;
align-items: center;
.config-software-setting-copy > div {
display: grid;
min-width: 0;
gap: 4px;
}
.config-software-description p {
margin: 0;
color: var(--theme-6d6760);
.config-software-setting-copy strong {
color: var(--theme-39352f);
font-size: var(--font-size-body);
line-height: var(--line-height-body);
}
.config-software-field {
display: grid;
align-content: center;
gap: 8px;
color: var(--theme-39352f);
font-size: var(--font-size-body);
font-weight: 700;
.config-software-settings-list {
overflow: hidden;
border: 1px solid var(--theme-d5d2cb);
border-radius: 10px;
background: var(--theme-fffdf8);
}
.config-software-field .config-network-input {
.config-software-setting-row {
display: flex;
min-height: 92px;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 17px 20px;
}
.config-software-setting-row + .config-software-setting-row {
border-top: 1px solid var(--theme-e3e1db);
}
.config-software-setting-copy {
display: flex;
min-width: 0;
align-items: center;
gap: 14px;
}
.config-software-setting-icon {
width: 40px;
height: 40px;
background: var(--theme-faf8f2);
}
.config-software-setting-copy small {
color: var(--theme-7a746c);
font-size: var(--font-size-meta);
font-weight: 400;
line-height: var(--line-height-body);
}
.config-software-select {
display: grid;
width: 228px;
flex: none;
}
.config-software-select .config-network-input {
height: 42px;
}
@@ -2506,13 +2548,43 @@ textarea:focus-visible {
}
@media (max-width: 700px) {
.config-software-content {
grid-template-columns: minmax(0, 1fr);
.config-software-panel .config-panel-heading {
align-items: flex-start;
}
.config-software-description,
.config-software-field {
min-height: 0;
.config-software-setting-row {
min-height: 84px;
gap: 16px;
padding: 15px 16px;
}
.config-software-close-row {
align-items: flex-start;
flex-direction: column;
}
.config-software-select {
width: 100%;
padding-left: 54px;
}
}
@media (max-width: 480px) {
.config-software-setting-row {
padding: 14px;
}
.config-software-setting-copy {
gap: 11px;
}
.config-software-setting-icon {
width: 36px;
height: 36px;
}
.config-software-select {
padding-left: 47px;
}
}