mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
OPENIDEA_CONFIG_DIR, OPENIDEA_DATA_DIR and OPENIDEA_LOG_DIR take an absolute path, like OPENIDEA_LOCAL_DATA_DIR, and the IDE uses <path>/<identifier> under it. Every place that resolved these directories now goes through ide_host::data_dirs (app state, invalidate caches, window zoom and window state, the MCP stdio bridge, logging, the openidea-plugin CLI), and the frontend takes the user data directory from app_info instead of Tauri's appDataDir. A relative path for the configuration or user data shows an error and exits. Closes #2814
540 lines
22 KiB
Rust
540 lines
22 KiB
Rust
//! Restore GTK geometry after mapping, using the scale of the window's actual output.
|
|
|
|
mod geometry;
|
|
#[cfg(target_os = "linux")]
|
|
use geometry::*;
|
|
use tauri::utils::config::WindowConfig;
|
|
|
|
fn build_hidden(config: &mut WindowConfig) -> bool {
|
|
std::mem::replace(&mut config.visible, false)
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
mod runtime {
|
|
use super::*;
|
|
use parking_lot::Mutex;
|
|
use std::collections::HashMap;
|
|
use std::sync::OnceLock;
|
|
use tauri::{AppHandle, Listener, Manager, Window, WindowEvent, plugin::TauriPlugin};
|
|
|
|
const FILE: &str = tauri_plugin_window_state::DEFAULT_FILENAME;
|
|
|
|
#[derive(Default)]
|
|
enum Phase {
|
|
#[default]
|
|
Mapping,
|
|
Size {
|
|
target: Size,
|
|
scale: f64,
|
|
request: u64,
|
|
},
|
|
Mode,
|
|
Tracking,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Pending {
|
|
limits: Limits,
|
|
configured: bool,
|
|
presented: bool,
|
|
presentation_request: u64,
|
|
saved: Geometry,
|
|
normal: Size,
|
|
history: ModeHistory,
|
|
restore_position: bool,
|
|
phase: Phase,
|
|
mode_requested: bool,
|
|
mode_request: u64,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Cache {
|
|
saved: HashMap<String, Geometry>,
|
|
windows: HashMap<String, Pending>,
|
|
next_request: u64,
|
|
}
|
|
|
|
struct Frame {
|
|
size: Size,
|
|
scale: f64,
|
|
mode: Mode,
|
|
position: Option<tauri::PhysicalPosition<i32>>,
|
|
monitors: Vec<tauri::Monitor>,
|
|
work_area: Option<Size>,
|
|
visible: bool,
|
|
}
|
|
|
|
static WAYLAND: OnceLock<bool> = OnceLock::new();
|
|
|
|
/// GDK's backend, as tao tests it (`GDK_BACKEND=x11` under a Wayland session is X11).
|
|
/// Known once the plugin is set up, before any window exists.
|
|
pub fn is_wayland() -> bool {
|
|
WAYLAND.get().copied().unwrap_or(false)
|
|
}
|
|
|
|
/// The client size that `set_size` (`gtk_window_resize`) takes. On Wayland, decorated windows
|
|
/// get a GTK CSD shadow margin that tao's inner and outer sizes (the configure size) include.
|
|
pub fn content_size(window: &Window) -> tauri::Result<tauri::PhysicalSize<u32>> {
|
|
let inner = window.inner_size()?;
|
|
if !is_wayland() {
|
|
return Ok(inner);
|
|
}
|
|
// GTK objects belong to the main thread. `gtk_window_get_size` follows the configure
|
|
// synchronously; the webview's allocation only catches up on the next layout.
|
|
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
|
let cloned = window.clone();
|
|
window.run_on_main_thread(move || {
|
|
let sizes = cloned.gtk_window().ok().zip(cloned.inner_size().ok()).zip(cloned.scale_factor().ok());
|
|
let _ = tx.send(sizes.map(|((gtk, inner), scale)| {
|
|
let (width, height) = gtk::prelude::GtkWindowExt::size(>k);
|
|
let content = tauri::LogicalSize::new(width.max(0) as u32, height.max(0) as u32);
|
|
(content.to_physical::<u32>(scale), inner)
|
|
}));
|
|
})?;
|
|
let Some((content, inner)) = rx.recv().ok().flatten() else { return Ok(inner) };
|
|
// An unmapped window reports its size request; never exceed the configure size.
|
|
let usable =
|
|
content.width > 1 && content.height > 1 && content.width <= inner.width && content.height <= inner.height;
|
|
Ok(if usable { content } else { inner })
|
|
}
|
|
|
|
fn frame(window: &Window) -> Option<Frame> {
|
|
let size = content_size(window).ok()?;
|
|
Some(Frame {
|
|
visible: window.is_visible().ok()?,
|
|
size: Size(size.width, size.height),
|
|
scale: window.scale_factor().ok()?,
|
|
mode: Mode {
|
|
maximized: window.is_maximized().ok()?,
|
|
fullscreen: window.is_fullscreen().ok()?,
|
|
minimized: window.is_minimized().ok()?,
|
|
},
|
|
position: window.outer_position().ok(),
|
|
monitors: window.available_monitors().unwrap_or_default(),
|
|
work_area: window
|
|
.current_monitor()
|
|
.ok()
|
|
.flatten()
|
|
.map(|m| Size(m.work_area().size.width, m.work_area().size.height)),
|
|
})
|
|
}
|
|
|
|
fn key(label: &str) -> &str {
|
|
crate::windows::state_key(label)
|
|
}
|
|
|
|
pub fn prepare(app: &AppHandle, config: &mut WindowConfig) -> bool {
|
|
let cache = app.state::<Mutex<Cache>>();
|
|
let mut cache = cache.lock();
|
|
let saved = cache.saved.get(key(&config.label)).cloned().unwrap_or_else(|| Geometry {
|
|
maximized: config.maximized,
|
|
fullscreen: config.fullscreen,
|
|
..Default::default()
|
|
});
|
|
cache.next_request += 1;
|
|
let presentation_request = cache.next_request;
|
|
let pending = Pending {
|
|
presentation_request,
|
|
limits: Limits {
|
|
min: (config.min_width.unwrap_or(1.0), config.min_height.unwrap_or(1.0)),
|
|
max: (config.max_width, config.max_height),
|
|
},
|
|
normal: Size(saved.width, saved.height),
|
|
saved,
|
|
restore_position: true,
|
|
..Default::default()
|
|
};
|
|
if pending.saved.width > 0 && pending.saved.height > 0 {
|
|
// This only reduces the first-frame jump; mapped geometry is authoritative.
|
|
if let Some((hint, scale)) = first_map_hint(app, pending.limits, pending.normal) {
|
|
config.width = f64::from(hint.0) / scale;
|
|
config.height = f64::from(hint.1) / scale;
|
|
}
|
|
}
|
|
cache.windows.insert(config.label.clone(), pending);
|
|
// Establish a normal window before asking GTK to maximize or fullscreen it.
|
|
config.maximized = false;
|
|
config.fullscreen = false;
|
|
build_hidden(config)
|
|
}
|
|
|
|
/// The first-map size hint and its scale. X11 has a primary monitor to convert the
|
|
/// saved physical size up front; Wayland has none — the compositor picks the output
|
|
/// and its scale after mapping — so the hint stays below Mutter's auto-maximize
|
|
/// threshold on every output.
|
|
fn first_map_hint(app: &AppHandle, limits: Limits, saved: Size) -> Option<(Size, f64)> {
|
|
if !is_wayland() {
|
|
let monitor = app.primary_monitor().ok().flatten();
|
|
let scale = monitor.as_ref().map_or(1.0, |m| m.scale_factor());
|
|
let area = monitor.map(|m| Size(m.work_area().size.width, m.work_area().size.height));
|
|
return Some((restore_size(saved, scale, limits.in_work_area(area, scale)), scale));
|
|
}
|
|
let outputs: Vec<(f64, Size)> = app
|
|
.available_monitors()
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|monitor| {
|
|
(monitor.scale_factor(), Size(monitor.work_area().size.width, monitor.work_area().size.height))
|
|
})
|
|
.collect();
|
|
compositor_first_map_hint(saved, limits, &outputs)
|
|
}
|
|
|
|
fn sample(window: &Window, allow_restore: bool, accept_constrained_size: bool) {
|
|
let Some(frame) = frame(window) else { return };
|
|
if !frame.visible {
|
|
return;
|
|
}
|
|
let cache = window.state::<Mutex<Cache>>();
|
|
let mut cache = cache.lock();
|
|
cache.next_request += 1;
|
|
let request = cache.next_request;
|
|
let Some(pending) = cache.windows.get_mut(window.label()) else { return };
|
|
if !pending.presented && pending.saved.width == 0 {
|
|
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
|
|
}
|
|
if !pending.configured || !pending.presented {
|
|
return;
|
|
}
|
|
if !allow_restore && !matches!(pending.phase, Phase::Tracking) {
|
|
return;
|
|
}
|
|
let transition = if matches!(pending.phase, Phase::Tracking) {
|
|
pending.history.observe(frame.mode, allow_restore)
|
|
} else {
|
|
NormalTransition::Stable
|
|
};
|
|
if transition == NormalTransition::WaitForResize {
|
|
let normal = pending.normal;
|
|
let saved = cache.saved.entry(key(window.label()).to_owned()).or_default();
|
|
(saved.width, saved.height) = (normal.0, normal.1);
|
|
saved.maximized = frame.mode.maximized;
|
|
saved.fullscreen = frame.mode.fullscreen;
|
|
return;
|
|
}
|
|
if transition == NormalTransition::Restore {
|
|
// The compositor may have changed output/DPI while maximized or minimized.
|
|
pending.saved.set_normal_size(pending.normal);
|
|
pending.phase = Phase::Mapping;
|
|
pending.mode_requested = false;
|
|
}
|
|
let mut place_aux = false;
|
|
let mut position = None;
|
|
if matches!(pending.phase, Phase::Mapping) {
|
|
if pending.restore_position {
|
|
pending.restore_position = false;
|
|
let saved = &pending.saved;
|
|
let (x, y) = if saved.maximized { (saved.prev_x, saved.prev_y) } else { (saved.x, saved.y) };
|
|
if frame.monitors.iter().any(|m| {
|
|
let p = m.position();
|
|
let s = m.size();
|
|
i64::from(x) < i64::from(p.x) + i64::from(s.width)
|
|
&& i64::from(x) + i64::from(saved.width) > i64::from(p.x)
|
|
&& i64::from(y) < i64::from(p.y) + i64::from(s.height)
|
|
&& i64::from(y) + i64::from(saved.height) > i64::from(p.y)
|
|
}) {
|
|
position = Some(tauri::PhysicalPosition::new(x, y));
|
|
}
|
|
}
|
|
if pending.saved.width > 0 && pending.saved.height > 0 {
|
|
let target = restore_size(
|
|
Size(pending.saved.width, pending.saved.height),
|
|
frame.scale,
|
|
pending.limits.in_work_area(frame.work_area, frame.scale),
|
|
);
|
|
pending.phase = Phase::Size { target, scale: frame.scale, request: 0 };
|
|
} else {
|
|
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
|
|
pending.phase = Phase::Mode;
|
|
}
|
|
}
|
|
if let Phase::Size { target, scale, request: sent } = pending.phase {
|
|
if frame.size != target && !accept_constrained_size {
|
|
if sent != 0 && scale == frame.scale {
|
|
return;
|
|
}
|
|
// Re-evaluate after output scale changes, rather than reusing monitor 0's scale.
|
|
let target = restore_size(
|
|
Size(pending.saved.width, pending.saved.height),
|
|
frame.scale,
|
|
pending.limits.in_work_area(frame.work_area, frame.scale),
|
|
);
|
|
pending.phase = Phase::Size { target, scale: frame.scale, request };
|
|
drop(cache);
|
|
if let Some(position) = position {
|
|
let _ = window.set_position(position);
|
|
}
|
|
if let Err(error) = window.set_size(tauri::PhysicalSize::new(target.0, target.1)) {
|
|
tracing::warn!("cannot restore Linux window size: {error}");
|
|
}
|
|
settle_constrained_size(window, request);
|
|
return;
|
|
}
|
|
if scale != frame.scale && !accept_constrained_size {
|
|
pending.phase = Phase::Mapping;
|
|
drop(cache);
|
|
sample(window, true, false);
|
|
return;
|
|
}
|
|
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
|
|
pending.phase = Phase::Mode;
|
|
}
|
|
if matches!(pending.phase, Phase::Mode) {
|
|
let (maximize, fullscreen) = (pending.saved.maximized, pending.saved.fullscreen);
|
|
let reached = if fullscreen { frame.mode.fullscreen } else { !maximize || frame.mode.maximized };
|
|
let action = mode_action(reached, pending.mode_requested, accept_constrained_size);
|
|
if action == ModeAction::Wait {
|
|
return;
|
|
}
|
|
if action == ModeAction::Request {
|
|
pending.mode_requested = true;
|
|
pending.mode_request = request;
|
|
let mut snapshot = pending.saved.clone();
|
|
(snapshot.width, snapshot.height) = (pending.normal.0, pending.normal.1);
|
|
cache.saved.insert(key(window.label()).to_owned(), snapshot);
|
|
drop(cache);
|
|
if let Some(position) = position {
|
|
let _ = window.set_position(position);
|
|
}
|
|
if maximize {
|
|
let _ = window.maximize();
|
|
}
|
|
if fullscreen {
|
|
let _ = window.set_fullscreen(true);
|
|
}
|
|
settle_constrained_size(window, request);
|
|
return;
|
|
}
|
|
pending.phase = Phase::Tracking;
|
|
place_aux = crate::windows::auxiliary_parent(window.label()).is_some();
|
|
}
|
|
pending.history = ModeHistory(frame.mode);
|
|
pending.normal = remember_size(pending.normal, frame.size, false, frame.mode);
|
|
let normal = pending.normal;
|
|
let saved = cache.saved.entry(key(window.label()).to_owned()).or_default();
|
|
(saved.width, saved.height) = (normal.0, normal.1);
|
|
saved.maximized = frame.mode.maximized;
|
|
saved.fullscreen = frame.mode.fullscreen;
|
|
if frame.mode.normal()
|
|
&& let Some(pos) = frame.position
|
|
{
|
|
(saved.x, saved.y, saved.prev_x, saved.prev_y) = (pos.x, pos.y, pos.x, pos.y);
|
|
}
|
|
drop(cache);
|
|
if let Some(position) = position {
|
|
let _ = window.set_position(position);
|
|
}
|
|
if place_aux {
|
|
let app = window.app_handle().clone();
|
|
let label = window.label().to_owned();
|
|
let _ = window.run_on_main_thread(move || {
|
|
if let Some(parent) = crate::windows::auxiliary_parent(&label).and_then(|p| app.get_webview_window(p))
|
|
&& let Some(aux) = app.get_webview_window(&label)
|
|
&& let Err(error) = crate::windows::place_over_parent(&aux, &parent)
|
|
{
|
|
tracing::warn!("cannot fit restored auxiliary window: {error}");
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
fn settle_constrained_size(window: &Window, request: u64) {
|
|
let window = window.clone();
|
|
tauri::async_runtime::spawn(async move {
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
let cloned = window.clone();
|
|
let _ = window.run_on_main_thread(move || {
|
|
let waiting = cloned.state::<Mutex<Cache>>().lock().windows.get(cloned.label()).is_some_and(|p| {
|
|
matches!(p.phase, Phase::Size { request: current, .. } if current == request)
|
|
|| matches!(p.phase, Phase::Mode) && p.mode_request == request
|
|
});
|
|
if waiting {
|
|
// Window-manager constraints may reject a size without producing an exact match.
|
|
sample(&cloned, true, true);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
pub fn normal_size_requested(app: &AppHandle, label: &str, size: tauri::PhysicalSize<u32>) {
|
|
let cache = app.state::<Mutex<Cache>>();
|
|
let mut cache = cache.lock();
|
|
cache.next_request += 1;
|
|
let request = cache.next_request;
|
|
if let Some(pending) = cache.windows.get_mut(label) {
|
|
pending.normal = Size(size.width, size.height);
|
|
pending.saved.set_normal_size(pending.normal);
|
|
pending.mode_requested = false;
|
|
pending.presented = false;
|
|
pending.presentation_request = request;
|
|
pending.phase = Phase::Mapping;
|
|
}
|
|
let saved = cache.saved.entry(key(label).to_owned()).or_default();
|
|
saved.set_normal_size(Size(size.width, size.height));
|
|
drop(cache);
|
|
if let Some(webview) = app.get_webview_window(label) {
|
|
request_presentation(webview.as_ref(), request);
|
|
}
|
|
}
|
|
|
|
fn request_presentation(webview: &tauri::Webview, request: u64) {
|
|
// A configure precedes output-enter. A second animation frame follows the
|
|
// first paint, allowing GTK to learn the scale of the assigned output.
|
|
let script = r#"
|
|
(() => {
|
|
const state = window.__OPENIDEA_WINDOW_GEOMETRY__ ??= { request: 0, scheduled: false };
|
|
state.request = __REQUEST_ID__;
|
|
state.ready ??= () => {
|
|
if (document.hidden || state.scheduled) return;
|
|
const request = state.request;
|
|
state.scheduled = true;
|
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
state.scheduled = false;
|
|
if (document.hidden) return;
|
|
const api = window.__TAURI_INTERNALS__;
|
|
const label = api?.metadata?.currentWindow?.label;
|
|
if (label) api.invoke('plugin:event|emit', {
|
|
event: 'openidea://window-state-presented', payload: { label, request }
|
|
});
|
|
if (request !== state.request) state.ready();
|
|
}));
|
|
};
|
|
if (!state.listening) {
|
|
state.listening = true;
|
|
document.addEventListener('visibilitychange', state.ready);
|
|
}
|
|
state.ready();
|
|
})();
|
|
"#
|
|
.replace("__REQUEST_ID__", &request.to_string());
|
|
let _ = webview.eval(script);
|
|
}
|
|
|
|
pub fn save_before_close(app: &AppHandle) {
|
|
for window in app.webview_windows().values() {
|
|
sample(&window.as_ref().window(), false, false);
|
|
}
|
|
persist(app);
|
|
}
|
|
|
|
fn persist(app: &AppHandle) {
|
|
let write = || -> Result<(), Box<dyn std::error::Error>> {
|
|
let dir = ide_host::data_dirs::config_dir(&app.config().identifier)?;
|
|
std::fs::create_dir_all(&dir)?;
|
|
let cache = app.state::<Mutex<Cache>>();
|
|
let data = serde_json::to_vec_pretty(&cache.lock().saved)?;
|
|
let temporary = dir.join(format!("{FILE}.tmp"));
|
|
std::fs::write(&temporary, data)?;
|
|
std::fs::rename(temporary, dir.join(FILE))?;
|
|
Ok(())
|
|
};
|
|
if let Err(error) = write() {
|
|
tracing::warn!("cannot save Linux window state: {error}");
|
|
}
|
|
}
|
|
|
|
pub fn plugin() -> TauriPlugin<tauri::Wry> {
|
|
tauri::plugin::Builder::new("linux-window-state")
|
|
.setup(|app, _| {
|
|
// Plugins are set up on the main thread once GTK is initialised, before any window exists.
|
|
use gtk::gdk::prelude::DisplayExtManual;
|
|
let _ = WAYLAND.set(gtk::gdk::Display::default().is_some_and(|d| d.backend().is_wayland()));
|
|
let saved = ide_host::data_dirs::config_dir(&app.config().identifier)
|
|
.ok()
|
|
.and_then(|dir| std::fs::read(dir.join(FILE)).ok())
|
|
.and_then(|data| serde_json::from_slice(&data).ok())
|
|
.unwrap_or_default();
|
|
app.manage(Mutex::new(Cache { saved, ..Default::default() }));
|
|
let handle = app.clone();
|
|
app.listen("openidea://window-state-presented", move |event| {
|
|
#[derive(serde::Deserialize)]
|
|
struct Presented {
|
|
label: String,
|
|
request: u64,
|
|
}
|
|
let Ok(presented) = serde_json::from_str::<Presented>(event.payload()) else { return };
|
|
let Some(webview) = handle.get_webview_window(&presented.label) else { return };
|
|
let window = webview.as_ref().window();
|
|
let cloned = window.clone();
|
|
let _ = window.run_on_main_thread(move || {
|
|
if !cloned.is_visible().unwrap_or(false) {
|
|
return;
|
|
}
|
|
if let Some(pending) = cloned.state::<Mutex<Cache>>().lock().windows.get_mut(cloned.label()) {
|
|
if pending.presentation_request != presented.request {
|
|
return;
|
|
}
|
|
pending.presented = true;
|
|
}
|
|
sample(&cloned, true, false);
|
|
});
|
|
});
|
|
Ok(())
|
|
})
|
|
.on_page_load(|webview, payload| {
|
|
if payload.event() == tauri::webview::PageLoadEvent::Finished {
|
|
let request = webview
|
|
.state::<Mutex<Cache>>()
|
|
.lock()
|
|
.windows
|
|
.get(webview.window().label())
|
|
.map(|p| p.presentation_request);
|
|
if let Some(request) = request {
|
|
request_presentation(webview, request);
|
|
}
|
|
}
|
|
})
|
|
.on_window_ready(|window| {
|
|
let cloned = window.clone();
|
|
window.on_window_event(move |event| match event {
|
|
WindowEvent::Resized(_) => {
|
|
if let Some(pending) = cloned.state::<Mutex<Cache>>().lock().windows.get_mut(cloned.label()) {
|
|
pending.configured = true;
|
|
}
|
|
let request = cloned
|
|
.state::<Mutex<Cache>>()
|
|
.lock()
|
|
.windows
|
|
.get(cloned.label())
|
|
.filter(|p| !p.presented)
|
|
.map(|p| p.presentation_request);
|
|
if let Some(request) = request
|
|
&& let Some(webview) = cloned.app_handle().get_webview_window(cloned.label())
|
|
{
|
|
request_presentation(webview.as_ref(), request);
|
|
}
|
|
sample(&cloned, true, false);
|
|
}
|
|
WindowEvent::Moved(_) => sample(&cloned, false, false),
|
|
WindowEvent::Destroyed => {
|
|
cloned.state::<Mutex<Cache>>().lock().windows.remove(cloned.label());
|
|
}
|
|
_ => {}
|
|
});
|
|
})
|
|
.on_event(|app, event| {
|
|
if let tauri::RunEvent::Exit = event {
|
|
persist(app);
|
|
}
|
|
})
|
|
.build()
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
pub use runtime::{content_size, is_wayland, normal_size_requested, plugin, prepare, save_before_close};
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn hidden_creation_preserves_geometry() {
|
|
let mut config = WindowConfig { width: 1201.0, height: 801.0, ..Default::default() };
|
|
assert!(build_hidden(&mut config));
|
|
assert!(!config.visible);
|
|
assert_eq!((config.width, config.height), (1201.0, 801.0));
|
|
assert!(!build_hidden(&mut config));
|
|
}
|
|
}
|