feat: persist window size and dynamically adjust panel spacing on startup
This commit is contained in:
1 parent
d1f1921086
commit
122f6c438c
3 files changed
+333
-6
No files matched your search
+274
-3
@@ -35,7 +35,7 @@ use tauri::{
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::{Emitter, LogicalSize, Manager};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use zip::ZipArchive;
|
||||
@@ -62,6 +62,9 @@ const CORE_VERSION_FILE: &str = "core-version.txt";
|
||||
const CORE_CHECKSUMS_FILE: &str = "checksums.txt";
|
||||
const GUI_CONFIG_FILE: &str = "config.toml";
|
||||
const LEGACY_GUI_CONFIG_FILE: &str = "cpa-gui.yaml";
|
||||
const MIN_MAIN_WINDOW_WIDTH: u32 = 640;
|
||||
const MIN_MAIN_WINDOW_HEIGHT: u32 = 600;
|
||||
const MAX_SAVED_WINDOW_DIMENSION: u32 = 16_384;
|
||||
const OAUTH_DIR_NAME: &str = "oauth";
|
||||
const DEFAULT_API_KEY: &str = "123456";
|
||||
const DEFAULT_API_KEY_REMARK: &str = "内置密钥";
|
||||
@@ -187,6 +190,16 @@ struct GuiConfigState {
|
||||
inner: Mutex<GuiConfigFile>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct SavedWindowSize {
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
struct MainWindowSizeState {
|
||||
inner: Mutex<Option<SavedWindowSize>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct AgentConfigStatusCache {
|
||||
refresh_lock: Mutex<()>,
|
||||
@@ -390,6 +403,8 @@ struct GuiConfigFile {
|
||||
port: u16,
|
||||
allow_lan: bool,
|
||||
run_on_startup: bool,
|
||||
window_width: Option<u32>,
|
||||
window_height: Option<u32>,
|
||||
auth_dir: String,
|
||||
#[serde(deserialize_with = "deserialize_gui_api_keys")]
|
||||
api_keys: Vec<GuiApiKeyEntry>,
|
||||
@@ -443,6 +458,8 @@ impl Default for GuiConfigFile {
|
||||
port: 8317,
|
||||
allow_lan: false,
|
||||
run_on_startup: false,
|
||||
window_width: None,
|
||||
window_height: None,
|
||||
auth_dir: env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.parent().map(|parent| parent.join(OAUTH_DIR_NAME)))
|
||||
@@ -1006,6 +1023,30 @@ impl CoreProcessState {
|
||||
}
|
||||
}
|
||||
|
||||
impl MainWindowSizeState {
|
||||
fn new(size: Option<SavedWindowSize>) -> Self {
|
||||
Self {
|
||||
inner: Mutex::new(size),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Result<Option<SavedWindowSize>, String> {
|
||||
self.inner
|
||||
.lock()
|
||||
.map(|size| *size)
|
||||
.map_err(|_| "主窗口尺寸状态锁已损坏".to_string())
|
||||
}
|
||||
|
||||
fn replace(&self, size: SavedWindowSize) -> Result<(), String> {
|
||||
let mut current = self
|
||||
.inner
|
||||
.lock()
|
||||
.map_err(|_| "主窗口尺寸状态锁已损坏".to_string())?;
|
||||
*current = Some(size);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl GuiConfigState {
|
||||
fn new(config: GuiConfigFile) -> Self {
|
||||
Self {
|
||||
@@ -1042,6 +1083,14 @@ impl GuiConfigState {
|
||||
})
|
||||
}
|
||||
|
||||
fn set_window_size(&self, size: SavedWindowSize) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.window_width = Some(size.width);
|
||||
config.window_height = Some(size.height);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn set_management_secret_key(&self, secret_key: String) -> Result<GuiConfigFile, String> {
|
||||
self.update(|config| {
|
||||
config.management_secret_key = secret_key;
|
||||
@@ -8701,6 +8750,123 @@ fn merge_core_api_keys_with_gui_metadata(
|
||||
merged
|
||||
}
|
||||
|
||||
fn normalized_saved_window_size(width: u32, height: u32) -> SavedWindowSize {
|
||||
SavedWindowSize {
|
||||
width: width.clamp(MIN_MAIN_WINDOW_WIDTH, MAX_SAVED_WINDOW_DIMENSION),
|
||||
height: height.clamp(MIN_MAIN_WINDOW_HEIGHT, MAX_SAVED_WINDOW_DIMENSION),
|
||||
}
|
||||
}
|
||||
|
||||
fn configured_window_size(config: &GuiConfigFile) -> Option<SavedWindowSize> {
|
||||
match (config.window_width, config.window_height) {
|
||||
(Some(width), Some(height)) => Some(normalized_saved_window_size(width, height)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn logical_window_size_from_physical(
|
||||
physical_size: &tauri::PhysicalSize<u32>,
|
||||
scale_factor: f64,
|
||||
) -> Option<SavedWindowSize> {
|
||||
if !scale_factor.is_finite() || scale_factor <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let width = (f64::from(physical_size.width) / scale_factor).round() as u32;
|
||||
let height = (f64::from(physical_size.height) / scale_factor).round() as u32;
|
||||
if width < MIN_MAIN_WINDOW_WIDTH || height < MIN_MAIN_WINDOW_HEIGHT {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(normalized_saved_window_size(width, height))
|
||||
}
|
||||
|
||||
fn fit_window_size_to_current_monitor<R: tauri::Runtime>(
|
||||
window: &tauri::WebviewWindow<R>,
|
||||
saved_size: SavedWindowSize,
|
||||
) -> SavedWindowSize {
|
||||
let monitor = window
|
||||
.current_monitor()
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| window.primary_monitor().ok().flatten());
|
||||
let Some(monitor) = monitor else {
|
||||
return saved_size;
|
||||
};
|
||||
|
||||
let scale_factor = monitor.scale_factor();
|
||||
if !scale_factor.is_finite() || scale_factor <= 0.0 {
|
||||
return saved_size;
|
||||
}
|
||||
|
||||
let (frame_width, frame_height) = window
|
||||
.outer_size()
|
||||
.ok()
|
||||
.zip(window.inner_size().ok())
|
||||
.map(|(outer, inner)| {
|
||||
(
|
||||
outer.width.saturating_sub(inner.width),
|
||||
outer.height.saturating_sub(inner.height),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let work_area = monitor.work_area();
|
||||
let max_width =
|
||||
(f64::from(work_area.size.width.saturating_sub(frame_width)) / scale_factor).floor() as u32;
|
||||
let max_height = (f64::from(work_area.size.height.saturating_sub(frame_height)) / scale_factor)
|
||||
.floor() as u32;
|
||||
|
||||
SavedWindowSize {
|
||||
width: saved_size.width.min(max_width.max(MIN_MAIN_WINDOW_WIDTH)),
|
||||
height: saved_size
|
||||
.height
|
||||
.min(max_height.max(MIN_MAIN_WINDOW_HEIGHT)),
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_main_window_size(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let window_size_state = app.state::<MainWindowSizeState>();
|
||||
let Some(saved_size) = window_size_state.snapshot()? else {
|
||||
return Ok(());
|
||||
};
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "主窗口不存在,无法恢复窗口尺寸".to_string())?;
|
||||
let restored_size = fit_window_size_to_current_monitor(&window, saved_size);
|
||||
|
||||
window
|
||||
.set_size(LogicalSize::new(
|
||||
f64::from(restored_size.width),
|
||||
f64::from(restored_size.height),
|
||||
))
|
||||
.map_err(|error| format!("恢复主窗口尺寸失败: {error}"))?;
|
||||
window_size_state.replace(restored_size)
|
||||
}
|
||||
|
||||
fn persist_main_window_size(app: &tauri::AppHandle) -> Result<(), String> {
|
||||
let window_size_state = app.state::<MainWindowSizeState>();
|
||||
let saved_size = match window_size_state.snapshot()? {
|
||||
Some(size) => size,
|
||||
None => {
|
||||
let window = app
|
||||
.get_webview_window("main")
|
||||
.ok_or_else(|| "主窗口不存在,无法保存窗口尺寸".to_string())?;
|
||||
let physical_size = window
|
||||
.inner_size()
|
||||
.map_err(|error| format!("读取主窗口尺寸失败: {error}"))?;
|
||||
let scale_factor = window
|
||||
.scale_factor()
|
||||
.map_err(|error| format!("读取主窗口缩放比例失败: {error}"))?;
|
||||
logical_window_size_from_physical(&physical_size, scale_factor)
|
||||
.ok_or_else(|| "主窗口尺寸无效,已跳过保存".to_string())?
|
||||
}
|
||||
};
|
||||
|
||||
app.state::<GuiConfigState>()
|
||||
.set_window_size(saved_size)
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, String> {
|
||||
let mut changed = false;
|
||||
let normalized_locale = normalize_app_locale(&config.locale);
|
||||
@@ -8730,6 +8896,16 @@ fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result<bool, String> {
|
||||
config.management_secret_key = DEFAULT_MANAGEMENT_SECRET_KEY.to_string();
|
||||
changed = true;
|
||||
}
|
||||
let window_size = configured_window_size(config);
|
||||
let normalized_window_width = window_size.map(|size| size.width);
|
||||
let normalized_window_height = window_size.map(|size| size.height);
|
||||
if config.window_width != normalized_window_width
|
||||
|| config.window_height != normalized_window_height
|
||||
{
|
||||
config.window_width = normalized_window_width;
|
||||
config.window_height = normalized_window_height;
|
||||
changed = true;
|
||||
}
|
||||
let auth_dir = path_to_string(&fixed_oauth_dir()?);
|
||||
if config.auth_dir != auth_dir {
|
||||
config.auth_dir = auth_dir;
|
||||
@@ -10494,6 +10670,7 @@ fn main() {
|
||||
config
|
||||
}
|
||||
};
|
||||
let initial_window_size = configured_window_size(&gui_config);
|
||||
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(
|
||||
@@ -10506,8 +10683,36 @@ fn main() {
|
||||
.manage(CoreProcessState::default())
|
||||
.manage(usage::UsageCollectorState::default())
|
||||
.manage(GuiConfigState::new(gui_config))
|
||||
.manage(MainWindowSizeState::new(initial_window_size))
|
||||
.manage(AgentConfigStatusCache::default());
|
||||
|
||||
let app = app.on_window_event(|window, event| {
|
||||
if window.label() != "main" {
|
||||
return;
|
||||
}
|
||||
|
||||
let observed_size = match event {
|
||||
tauri::WindowEvent::Resized(physical_size) => {
|
||||
window.scale_factor().ok().and_then(|scale_factor| {
|
||||
logical_window_size_from_physical(physical_size, scale_factor)
|
||||
})
|
||||
}
|
||||
tauri::WindowEvent::ScaleFactorChanged {
|
||||
scale_factor,
|
||||
new_inner_size,
|
||||
..
|
||||
} => logical_window_size_from_physical(new_inner_size, *scale_factor),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(observed_size) = observed_size {
|
||||
let window_size_state = window.state::<MainWindowSizeState>();
|
||||
if let Err(error) = window_size_state.replace(observed_size) {
|
||||
eprintln!("记录主窗口尺寸失败: {error}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let app = app.on_window_event(|window, event| {
|
||||
if window.label() == "main" {
|
||||
@@ -10538,6 +10743,10 @@ fn main() {
|
||||
|
||||
let app = app
|
||||
.setup(move |app| {
|
||||
if let Err(error) = restore_main_window_size(app.handle()) {
|
||||
eprintln!("{error}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
setup_macos_tray(app)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -10652,13 +10861,19 @@ fn main() {
|
||||
.build(tauri::generate_context!())
|
||||
.expect("failed to build app");
|
||||
|
||||
app.run(|app_handle, event| {
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
app.run(|app_handle, event| match event {
|
||||
tauri::RunEvent::ExitRequested { .. } => {
|
||||
if let Err(error) = persist_main_window_size(app_handle) {
|
||||
eprintln!("保存主窗口尺寸失败: {error}");
|
||||
}
|
||||
}
|
||||
tauri::RunEvent::Exit => {
|
||||
usage::stop_usage_collector(app_handle);
|
||||
let process_state = app_handle.state::<CoreProcessState>();
|
||||
let gui_config_state = app_handle.state::<GuiConfigState>();
|
||||
shutdown_managed_core(process_state.inner(), gui_config_state.inner());
|
||||
}
|
||||
_ => {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10739,6 +10954,60 @@ mod tests {
|
||||
assert!(content.contains("routing-strategy = \"round-robin\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_window_size_round_trips_through_portable_config() {
|
||||
let config = GuiConfigFile {
|
||||
window_width: Some(1440),
|
||||
window_height: Some(900),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
|
||||
let content = toml::to_string_pretty(&config).unwrap();
|
||||
let restored = toml::from_str::<GuiConfigFile>(&content).unwrap();
|
||||
|
||||
assert!(content.contains("window-width = 1440"));
|
||||
assert!(content.contains("window-height = 900"));
|
||||
assert_eq!(
|
||||
configured_window_size(&restored),
|
||||
Some(SavedWindowSize {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_window_size_is_clamped_and_requires_both_dimensions() {
|
||||
let mut config = GuiConfigFile {
|
||||
window_width: Some(320),
|
||||
window_height: Some(30_000),
|
||||
..GuiConfigFile::default()
|
||||
};
|
||||
|
||||
assert!(sanitize_gui_config(&mut config).unwrap());
|
||||
assert_eq!(config.window_width, Some(MIN_MAIN_WINDOW_WIDTH));
|
||||
assert_eq!(config.window_height, Some(MAX_SAVED_WINDOW_DIMENSION));
|
||||
|
||||
config.window_height = None;
|
||||
assert!(sanitize_gui_config(&mut config).unwrap());
|
||||
assert_eq!(config.window_width, None);
|
||||
assert_eq!(config.window_height, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_window_size_uses_display_scale_and_ignores_minimized_sizes() {
|
||||
let physical_size = tauri::PhysicalSize::new(1500, 1000);
|
||||
assert_eq!(
|
||||
logical_window_size_from_physical(&physical_size, 1.25),
|
||||
Some(SavedWindowSize {
|
||||
width: 1200,
|
||||
height: 800,
|
||||
})
|
||||
);
|
||||
assert!(logical_window_size_from_physical(&tauri::PhysicalSize::new(0, 0), 1.0).is_none());
|
||||
assert!(logical_window_size_from_physical(&physical_size, 0.0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_agent_config_preserves_existing_fields() {
|
||||
let rendered = build_claude_agent_config(
|
||||
@@ -12199,6 +12468,8 @@ mod tests {
|
||||
port: 9527,
|
||||
allow_lan: true,
|
||||
run_on_startup: false,
|
||||
window_width: None,
|
||||
window_height: None,
|
||||
auth_dir: path_to_string(&fixed_oauth_dir().unwrap()),
|
||||
api_keys: vec![
|
||||
built_in_api_key_entry(),
|
||||
|
||||
@@ -1376,6 +1376,8 @@ mod tests {
|
||||
port: 8317,
|
||||
allow_lan: false,
|
||||
run_on_startup: false,
|
||||
window_width: None,
|
||||
window_height: None,
|
||||
auth_dir: String::new(),
|
||||
api_keys: Vec::new(),
|
||||
management_secret_key: "123456".to_string(),
|
||||
|
||||
+57
-3
@@ -639,13 +639,16 @@ input:focus-visible {
|
||||
.client-api-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.client-api-card {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #e3e1db;
|
||||
@@ -704,6 +707,7 @@ input:focus-visible {
|
||||
|
||||
.client-api-values {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
@@ -865,19 +869,69 @@ input:focus-visible {
|
||||
@container workspace (min-width: 840px) {
|
||||
.home-page {
|
||||
grid-template-columns: minmax(320px, 0.8fr) minmax(0, 1.2fr);
|
||||
align-items: start;
|
||||
grid-auto-rows: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.home-page > .home-layout {
|
||||
display: contents;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
align-self: stretch;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.home-page > .client-api-panel {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel,
|
||||
.home-page > .client-api-panel {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
row-gap: clamp(12px, 2vh, 22px);
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.home-page > .client-api-panel {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel > .panel-heading,
|
||||
.home-page > .client-api-panel > .panel-heading,
|
||||
.version-management-layout > .panel > .panel-heading {
|
||||
min-height: 0;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.version-management-layout {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-auto-rows: 1fr;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.version-management-layout > .panel {
|
||||
height: 100%;
|
||||
align-self: stretch;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
row-gap: clamp(12px, 2vh, 22px);
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel > .panel-detail-grid,
|
||||
.version-management-layout > .panel > .panel-detail-grid,
|
||||
.home-page > .client-api-panel > .client-api-grid {
|
||||
align-content: space-evenly;
|
||||
}
|
||||
|
||||
.home-page > .home-layout > .control-panel > .panel-action-row,
|
||||
.version-management-layout > .panel > .panel-action-row {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user