diff --git a/Cargo.lock b/Cargo.lock index 04a96ae16c..1f4e03b0a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6578,8 +6578,10 @@ dependencies = [ name = "perry-ui" version = "0.5.1563" dependencies = [ + "dirs", "perry-ffi", "perry-ui-model", + "tempfile", ] [[package]] diff --git a/changelog.d/10246-window-frame-persistence.md b/changelog.d/10246-window-frame-persistence.md new file mode 100644 index 0000000000..bd746a845c --- /dev/null +++ b/changelog.d/10246-window-frame-persistence.md @@ -0,0 +1,11 @@ +Add opt-in `App({ frameAutosaveName: "main", ... })` desktop window persistence +(#10170). macOS uses AppKit frame autosave; Windows and WinUI restore native +placement and maximized/fullscreen state; GTK4 restores normal size and window +state while leaving positioning to the compositor. Saved frames take precedence +over launch defaults, and empty or omitted names disable persistence. + +Persistence keys are scoped to the executable and window name. Windows/Linux +settings are validated and replaced atomically, minimized Windows sessions reopen +in their last non-minimized state, and fullscreen placement preserves the normal +frame. Add compiler regression coverage, storage tests, a native Windows +close/reopen test, TypeScript declarations, and a desktop example. diff --git a/crates/perry-codegen/src/lower_call/native/native_ui_appshell_branch.rs b/crates/perry-codegen/src/lower_call/native/native_ui_appshell_branch.rs index 6763290dad..c92561340c 100644 --- a/crates/perry-codegen/src/lower_call/native/native_ui_appshell_branch.rs +++ b/crates/perry-codegen/src/lower_call/native/native_ui_appshell_branch.rs @@ -233,182 +233,205 @@ (see types/perry/ui/index.d.ts)." ); }; - let mut title_ptr: String = "0".to_string(); - let mut width_d: String = "1024.0".to_string(); - let mut height_d: String = "768.0".to_string(); - let mut body_handle: String = "0".to_string(); - let mut icon_ptr: Option = None; - let mut window_state_ptr: Option = None; - let mut frameless_val: Option = None; - let mut level_ptr: Option = None; - let mut transparent_val: Option = None; - let mut vibrancy_ptr: Option = None; - let mut activation_policy_ptr: Option = None; - for (key, val) in &props { - match key.as_str() { - "title" => { - let v = lower_expr(ctx, val)?; - let blk = ctx.block(); - title_ptr = unbox_to_i64(blk, &v); - } - "width" => { - width_d = lower_expr(ctx, val)?; - } - "height" => { - height_d = lower_expr(ctx, val)?; - } - "body" => { - let v = lower_expr(ctx, val)?; - let blk = ctx.block(); - body_handle = unbox_to_i64(blk, &v); - } - "icon" => { - let v = lower_expr(ctx, val)?; - let blk = ctx.block(); - icon_ptr = Some(unbox_to_i64(blk, &v)); - } - // Issue #1280 — `windowState: "normal" | "maximized" | "fullscreen"`. - // Forwarded to perry_ui_app_set_window_state; each platform - // backend applies the state at app_run time. - "windowState" => { - let v = lower_expr(ctx, val)?; - let blk = ctx.block(); - window_state_ptr = Some(unbox_to_i64(blk, &v)); - } - // v0.4.11 launcher-style window options, lost in the Phase K - // Cranelift→LLVM cutover (2026-07-16 docs audit). `frameless` - // and `transparent` are forwarded as the raw NaN-boxed value — - // every platform backend only acts when the bits equal - // TAG_TRUE, exactly like the original Cranelift wiring. - "frameless" => { - frameless_val = Some(lower_expr(ctx, val)?); - } - "transparent" => { - transparent_val = Some(lower_expr(ctx, val)?); - } - // `level` / `vibrancy` / `activationPolicy` are strings. Route - // through the SSO-safe unbox (js_get_string_pointer_unified) - // rather than the raw pointer mask: short literals like - // "modal" or "menu" can arrive as inline SSO values whose low - // 48 bits are not a StringHeader pointer. - "level" => { - let v = lower_expr(ctx, val)?; - let blk = ctx.block(); - level_ptr = Some(crate::expr::unbox_str_handle(blk, &v)); - } - "vibrancy" => { - let v = lower_expr(ctx, val)?; - let blk = ctx.block(); - vibrancy_ptr = Some(crate::expr::unbox_str_handle(blk, &v)); - } - "activationPolicy" => { - let v = lower_expr(ctx, val)?; - let blk = ctx.block(); - activation_policy_ptr = Some(crate::expr::unbox_str_handle(blk, &v)); - } - _ => { - let _ = lower_expr(ctx, val)?; + return crate::rooting::with_rooted_group(ctx, 1, |ctx, frame_roots| { + let mut title_ptr: String = "0".to_string(); + let mut width_d: String = "1024.0".to_string(); + let mut height_d: String = "768.0".to_string(); + let mut body_handle: String = "0".to_string(); + let mut icon_ptr: Option = None; + let mut window_state_ptr: Option = None; + let mut frame_autosave_name_root: Option = None; + let mut frameless_val: Option = None; + let mut level_ptr: Option = None; + let mut transparent_val: Option = None; + let mut vibrancy_ptr: Option = None; + let mut activation_policy_ptr: Option = None; + for (key, val) in &props { + match key.as_str() { + "title" => { + let v = lower_expr(ctx, val)?; + let blk = ctx.block(); + title_ptr = unbox_to_i64(blk, &v); + } + "width" => { + width_d = lower_expr(ctx, val)?; + } + "height" => { + height_d = lower_expr(ctx, val)?; + } + "body" => { + let v = lower_expr(ctx, val)?; + let blk = ctx.block(); + body_handle = unbox_to_i64(blk, &v); + } + "icon" => { + let v = lower_expr(ctx, val)?; + let blk = ctx.block(); + icon_ptr = Some(unbox_to_i64(blk, &v)); + } + // Issue #1280 — `windowState: "normal" | "maximized" | "fullscreen"`. + // Forwarded to perry_ui_app_set_window_state; each platform + // backend applies the state at app_run time. + "windowState" => { + let v = lower_expr(ctx, val)?; + let blk = ctx.block(); + window_state_ptr = Some(unbox_to_i64(blk, &v)); + } + "frameAutosaveName" => { + // Later options (especially body builders) can collect. + // Keep the JS string rooted until the backend copies it. + frame_autosave_name_root = Some(frame_roots.lower(ctx, val, true)?); + } + // v0.4.11 launcher-style window options, lost in the Phase K + // Cranelift→LLVM cutover (2026-07-16 docs audit). `frameless` + // and `transparent` are forwarded as the raw NaN-boxed value — + // every platform backend only acts when the bits equal + // TAG_TRUE, exactly like the original Cranelift wiring. + "frameless" => { + frameless_val = Some(lower_expr(ctx, val)?); + } + "transparent" => { + transparent_val = Some(lower_expr(ctx, val)?); + } + // `level` / `vibrancy` / `activationPolicy` are strings. Route + // through the SSO-safe unbox (js_get_string_pointer_unified) + // rather than the raw pointer mask: short literals like + // "modal" or "menu" can arrive as inline SSO values whose low + // 48 bits are not a StringHeader pointer. + "level" => { + let v = lower_expr(ctx, val)?; + let blk = ctx.block(); + level_ptr = Some(crate::expr::unbox_str_handle(blk, &v)); + } + "vibrancy" => { + let v = lower_expr(ctx, val)?; + let blk = ctx.block(); + vibrancy_ptr = Some(crate::expr::unbox_str_handle(blk, &v)); + } + "activationPolicy" => { + let v = lower_expr(ctx, val)?; + let blk = ctx.block(); + activation_policy_ptr = Some(crate::expr::unbox_str_handle(blk, &v)); + } + _ => { + let _ = lower_expr(ctx, val)?; + } } } - } - ctx.pending_declares.push(( - "perry_ui_app_create".to_string(), - I64, - vec![I64, DOUBLE, DOUBLE], - )); - ctx.pending_declares.push(( - "perry_ui_app_set_icon".to_string(), - crate::types::VOID, - vec![I64], - )); - ctx.pending_declares.push(( - "perry_ui_app_set_window_state".to_string(), - crate::types::VOID, - vec![I64, I64], - )); - ctx.pending_declares.push(( - "perry_ui_app_set_frameless".to_string(), - crate::types::VOID, - vec![I64, DOUBLE], - )); - ctx.pending_declares.push(( - "perry_ui_app_set_level".to_string(), - crate::types::VOID, - vec![I64, I64], - )); - ctx.pending_declares.push(( - "perry_ui_app_set_transparent".to_string(), - crate::types::VOID, - vec![I64, DOUBLE], - )); - ctx.pending_declares.push(( - "perry_ui_app_set_vibrancy".to_string(), - crate::types::VOID, - vec![I64, I64], - )); - ctx.pending_declares.push(( - "perry_ui_app_set_activation_policy".to_string(), - crate::types::VOID, - vec![I64, I64], - )); - ctx.pending_declares.push(( - "perry_ui_app_set_body".to_string(), - crate::types::VOID, - vec![I64, I64], - )); - ctx.pending_declares.push(( - "perry_ui_app_run".to_string(), - crate::types::VOID, - vec![I64], - )); - let blk = ctx.block(); - let app_handle = blk.call( - I64, - "perry_ui_app_create", - &[(I64, &title_ptr), (DOUBLE, &width_d), (DOUBLE, &height_d)], - ); - if let Some(icon) = icon_ptr { - blk.call_void("perry_ui_app_set_icon", &[(I64, &icon)]); - } - if let Some(state_ptr) = window_state_ptr { - blk.call_void( - "perry_ui_app_set_window_state", - &[(I64, &app_handle), (I64, &state_ptr)], - ); - } - // Window properties are applied BEFORE the body so vibrancy / - // frameless can reconfigure the window (content view swap, style - // mask) before Auto Layout constraints are installed — same call - // order the Cranelift backend used (v0.4.11). - if let Some(v) = &frameless_val { - blk.call_void( - "perry_ui_app_set_frameless", - &[(I64, &app_handle), (DOUBLE, v)], - ); - } - if let Some(p) = &level_ptr { - blk.call_void("perry_ui_app_set_level", &[(I64, &app_handle), (I64, p)]); - } - if let Some(v) = &transparent_val { - blk.call_void( - "perry_ui_app_set_transparent", - &[(I64, &app_handle), (DOUBLE, v)], + ctx.pending_declares.push(( + "perry_ui_app_create".to_string(), + I64, + vec![I64, DOUBLE, DOUBLE], + )); + ctx.pending_declares.push(( + "perry_ui_app_set_icon".to_string(), + crate::types::VOID, + vec![I64], + )); + ctx.pending_declares.push(( + "perry_ui_app_set_window_state".to_string(), + crate::types::VOID, + vec![I64, I64], + )); + ctx.pending_declares.push(( + "perry_ui_app_set_frame_autosave_name".to_string(), + crate::types::VOID, + vec![I64, I64], + )); + ctx.pending_declares.push(( + "perry_ui_app_set_frameless".to_string(), + crate::types::VOID, + vec![I64, DOUBLE], + )); + ctx.pending_declares.push(( + "perry_ui_app_set_level".to_string(), + crate::types::VOID, + vec![I64, I64], + )); + ctx.pending_declares.push(( + "perry_ui_app_set_transparent".to_string(), + crate::types::VOID, + vec![I64, DOUBLE], + )); + ctx.pending_declares.push(( + "perry_ui_app_set_vibrancy".to_string(), + crate::types::VOID, + vec![I64, I64], + )); + ctx.pending_declares.push(( + "perry_ui_app_set_activation_policy".to_string(), + crate::types::VOID, + vec![I64, I64], + )); + ctx.pending_declares.push(( + "perry_ui_app_set_body".to_string(), + crate::types::VOID, + vec![I64, I64], + )); + ctx.pending_declares.push(( + "perry_ui_app_run".to_string(), + crate::types::VOID, + vec![I64], + )); + let blk = ctx.block(); + let app_handle = blk.call( + I64, + "perry_ui_app_create", + &[(I64, &title_ptr), (DOUBLE, &width_d), (DOUBLE, &height_d)], ); - } - if let Some(p) = &vibrancy_ptr { - blk.call_void("perry_ui_app_set_vibrancy", &[(I64, &app_handle), (I64, p)]); - } - if let Some(p) = &activation_policy_ptr { + if let Some(icon) = icon_ptr { + blk.call_void("perry_ui_app_set_icon", &[(I64, &icon)]); + } + if let Some(state_ptr) = window_state_ptr { + blk.call_void( + "perry_ui_app_set_window_state", + &[(I64, &app_handle), (I64, &state_ptr)], + ); + } + // Window properties are applied BEFORE the body so vibrancy / + // frameless can reconfigure the window (content view swap, style + // mask) before Auto Layout constraints are installed — same call + // order the Cranelift backend used (v0.4.11). + if let Some(v) = &frameless_val { + blk.call_void( + "perry_ui_app_set_frameless", + &[(I64, &app_handle), (DOUBLE, v)], + ); + } + if let Some(p) = &level_ptr { + blk.call_void("perry_ui_app_set_level", &[(I64, &app_handle), (I64, p)]); + } + if let Some(v) = &transparent_val { + blk.call_void( + "perry_ui_app_set_transparent", + &[(I64, &app_handle), (DOUBLE, v)], + ); + } + if let Some(p) = &vibrancy_ptr { + blk.call_void("perry_ui_app_set_vibrancy", &[(I64, &app_handle), (I64, p)]); + } + if let Some(p) = &activation_policy_ptr { + blk.call_void( + "perry_ui_app_set_activation_policy", + &[(I64, &app_handle), (I64, p)], + ); + } + if let Some(root) = frame_autosave_name_root { + let value = frame_roots.reread(ctx, root)?; + let blk = ctx.block(); + let name_ptr = crate::expr::unbox_str_handle(blk, &value); + blk.call_void( + "perry_ui_app_set_frame_autosave_name", + &[(I64, &app_handle), (I64, &name_ptr)], + ); + } + let blk = ctx.block(); blk.call_void( - "perry_ui_app_set_activation_policy", - &[(I64, &app_handle), (I64, p)], + "perry_ui_app_set_body", + &[(I64, &app_handle), (I64, &body_handle)], ); - } - blk.call_void( - "perry_ui_app_set_body", - &[(I64, &app_handle), (I64, &body_handle)], - ); - blk.call_void("perry_ui_app_run", &[(I64, &app_handle)]); - return Ok(double_literal(0.0)); + blk.call_void("perry_ui_app_run", &[(I64, &app_handle)]); + Ok(double_literal(0.0)) + }); } } diff --git a/crates/perry-codegen/tests/app_window_config_options.rs b/crates/perry-codegen/tests/app_window_config_options.rs index 934700c25d..1c141060da 100644 --- a/crates/perry-codegen/tests/app_window_config_options.rs +++ b/crates/perry-codegen/tests/app_window_config_options.rs @@ -151,12 +151,13 @@ fn compile_ir(name: &str, body: Vec) -> String { String::from_utf8(compile_module(&module(name, body), empty_opts()).unwrap()).unwrap() } -const WINDOW_OPTION_SETTERS: [&str; 5] = [ +const WINDOW_OPTION_SETTERS: [&str; 6] = [ "call void @perry_ui_app_set_frameless", "call void @perry_ui_app_set_level", "call void @perry_ui_app_set_transparent", "call void @perry_ui_app_set_vibrancy", "call void @perry_ui_app_set_activation_policy", + "call void @perry_ui_app_set_frame_autosave_name", ]; #[test] @@ -173,6 +174,7 @@ fn app_config_window_options_emit_ffi_calls() { ("transparent", Expr::Bool(true)), ("vibrancy", Expr::String("sidebar".to_string())), ("activationPolicy", Expr::String("accessory".to_string())), + ("frameAutosaveName", Expr::String("launcher".to_string())), ])], ); for setter in WINDOW_OPTION_SETTERS { @@ -192,6 +194,36 @@ fn app_config_window_options_emit_ffi_calls() { ); } +#[test] +fn named_frame_persistence_is_sso_safe_and_configured_before_run() { + for name in ["main", "settings-window-with-a-long-name", ""] { + for name_first in [true, false] { + let name_field = ("frameAutosaveName", Expr::String(name.to_string())); + let state_field = ("windowState", Expr::String("fullscreen".to_string())); + let fields = if name_first { + vec![name_field, state_field] + } else { + vec![state_field, name_field] + }; + let ir = compile_ir("app_frame_persistence", vec![app_call(fields)]); + let setter = "call void @perry_ui_app_set_frame_autosave_name"; + assert_eq!(ir.matches(setter).count(), 1, "IR:\n{ir}"); + assert!( + ir.contains("call i64 @js_get_string_pointer_unified"), + "IR:\n{ir}" + ); + let create = ir.find("call i64 @perry_ui_app_create").unwrap(); + let state = ir.find("call void @perry_ui_app_set_window_state").unwrap(); + let autosave = ir.find(setter).unwrap(); + let run = ir.find("call void @perry_ui_app_run").unwrap(); + assert!( + create < state && state < autosave && autosave < run, + "IR:\n{ir}" + ); + } + } +} + #[test] fn app_config_without_window_options_emits_no_setter_calls() { let ir = compile_ir( diff --git a/crates/perry-ui-android/src/ffi/tabbar_layout.rs b/crates/perry-ui-android/src/ffi/tabbar_layout.rs index c85e89f4cd..ceb49c7ae8 100644 --- a/crates/perry-ui-android/src/ffi/tabbar_layout.rs +++ b/crates/perry-ui-android/src/ffi/tabbar_layout.rs @@ -375,3 +375,7 @@ pub extern "C" fn perry_ui_app_set_activation_policy(_app_handle: i64, _value_pt /// Issue #1280 — Android apps run in a single full-screen Activity. Stub. #[no_mangle] pub extern "C" fn perry_ui_app_set_window_state(_app_handle: i64, _value_ptr: i64) {} + +/// Frame persistence only applies to repositionable desktop windows. +#[no_mangle] +pub extern "C" fn perry_ui_app_set_frame_autosave_name(_app_handle: i64, _value_ptr: i64) {} diff --git a/crates/perry-ui-gtk4/src/app.rs b/crates/perry-ui-gtk4/src/app.rs index 3904b7ab43..30758956db 100644 --- a/crates/perry-ui-gtk4/src/app.rs +++ b/crates/perry-ui-gtk4/src/app.rs @@ -73,6 +73,7 @@ struct AppEntry { activation_policy: Option, /// Issue #1280 — "maximized" | "fullscreen" | None (= "normal"). window_state: Option, + frame_autosave_name: Option, } extern "C" { @@ -122,6 +123,7 @@ pub fn app_create(title_ptr: *const u8, width: f64, height: f64) -> i64 { vibrancy: None, activation_policy: PENDING_ACTIVATION_POLICY.with(|p| p.borrow().clone()), window_state: None, + frame_autosave_name: None, }); apps.len() as i64 // 1-based handle }) @@ -291,8 +293,12 @@ pub fn app_run(_app_handle: i64) { // Issue #1280 — initial window state. GTK4 needs maximize() / // fullscreen() called before `present()` so the window appears // already in the requested state rather than flickering. - if let Some(ref state) = entry.window_state { - match state.as_str() { + let restored_state = entry + .frame_autosave_name + .as_deref() + .and_then(|name| crate::frame_persistence::install(&window, app, name)); + if let Some(state) = restored_state.or(entry.window_state.as_deref()) { + match state { "maximized" => window.maximize(), "fullscreen" => window.fullscreen(), _ => {} @@ -578,6 +584,19 @@ pub fn app_set_activation_policy(app_handle: i64, value_ptr: *const u8) { }); } +/// Record the stable name; restore after window setup and before presentation. +pub fn app_set_frame_autosave_name(app_handle: i64, value_ptr: *const u8) { + let name = unsafe { str_from_header(value_ptr) }; + APPS.with(|apps| { + if let Some(entry) = apps + .borrow_mut() + .get_mut(app_handle.saturating_sub(1) as usize) + { + entry.frame_autosave_name = (!name.is_empty()).then_some(name); + } + }); +} + /// Issue #1280 — initial window state. value_ptr points at a StringHeader /// for one of "normal" | "maximized" | "fullscreen". Anything else is /// silently ignored; the state is applied just before `window.present()`. diff --git a/crates/perry-ui-gtk4/src/ffi/app_window.rs b/crates/perry-ui-gtk4/src/ffi/app_window.rs index d4aeadda49..0bdb349abd 100644 --- a/crates/perry-ui-gtk4/src/ffi/app_window.rs +++ b/crates/perry-ui-gtk4/src/ffi/app_window.rs @@ -143,3 +143,9 @@ pub extern "C" fn perry_ui_window_set_size(window_handle: i64, width: f64, heigh pub extern "C" fn perry_ui_window_on_focus_lost(window_handle: i64, callback: f64) { window::on_focus_lost(window_handle, callback); } + +/// Opt in to desktop window frame persistence with an application-local key. +#[no_mangle] +pub extern "C" fn perry_ui_app_set_frame_autosave_name(app_handle: i64, value_ptr: i64) { + app::app_set_frame_autosave_name(app_handle, value_ptr as *const u8); +} diff --git a/crates/perry-ui-gtk4/src/frame_persistence.rs b/crates/perry-ui-gtk4/src/frame_persistence.rs new file mode 100644 index 0000000000..7877f1c88c --- /dev/null +++ b/crates/perry-ui-gtk4/src/frame_persistence.rs @@ -0,0 +1,56 @@ +use gtk4::prelude::*; +use gtk4::{Application, ApplicationWindow}; +use perry_ui::frame::{FrameStore, WindowFrame, WindowState}; + +fn save(window: &ApplicationWindow, store: &FrameStore) { + // GTK4 updates the default size when users resize, and preserves the + // normal size while maximized/fullscreen. Allocation includes overrides. + let (width, height) = window.default_size(); + let state = if window.is_fullscreen() { + WindowState::Fullscreen + } else if window.is_maximized() { + WindowState::Maximized + } else { + WindowState::Normal + }; + let _ = store.save(WindowFrame { + x: 0, + y: 0, + width, + height, + state, + }); +} + +pub(crate) fn install( + window: &ApplicationWindow, + app: &Application, + name: &str, +) -> Option<&'static str> { + let store = FrameStore::new(name)?; + let restored = store.load(); + if let Some(frame) = restored { + window.set_default_size(frame.width, frame.height); + } + let close_store = store.clone(); + window.connect_close_request(move |window| { + save(window, &close_store); + gtk4::glib::Propagation::Proceed + }); + // Application.quit() need not emit close-request. Use a weak reference + // so this callback does not keep a closed window alive. + let weak_window = window.downgrade(); + app.connect_shutdown(move |_| { + if let Some(window) = weak_window.upgrade() { + // A previously closed window was already saved by close-request. + if window.is_visible() { + save(&window, &store); + } + } + }); + restored.map(|frame| match frame.state { + WindowState::Normal => "normal", + WindowState::Maximized => "maximized", + WindowState::Fullscreen => "fullscreen", + }) +} diff --git a/crates/perry-ui-gtk4/src/lib.rs b/crates/perry-ui-gtk4/src/lib.rs index f51ea76796..40e8974d46 100644 --- a/crates/perry-ui-gtk4/src/lib.rs +++ b/crates/perry-ui-gtk4/src/lib.rs @@ -9,6 +9,7 @@ pub mod deeplinks_stub; pub mod dialog; pub mod drag_drop; pub mod file_dialog; +mod frame_persistence; mod gc; pub mod issue_552_stub; pub mod keyboard; diff --git a/crates/perry-ui-ios/src/ffi/dialogs_lifecycle.rs b/crates/perry-ui-ios/src/ffi/dialogs_lifecycle.rs index 3a2aa435fb..6329836afc 100644 --- a/crates/perry-ui-ios/src/ffi/dialogs_lifecycle.rs +++ b/crates/perry-ui-ios/src/ffi/dialogs_lifecycle.rs @@ -273,3 +273,7 @@ pub extern "C" fn perry_ui_toolbar_add_item( #[no_mangle] pub extern "C" fn perry_ui_toolbar_attach(_toolbar: i64) {} + +/// Frame persistence only applies to repositionable desktop windows. +#[no_mangle] +pub extern "C" fn perry_ui_app_set_frame_autosave_name(_app_handle: i64, _value_ptr: i64) {} diff --git a/crates/perry-ui-macos/src/app.rs b/crates/perry-ui-macos/src/app.rs index 3818a0e584..0b9163a948 100644 --- a/crates/perry-ui-macos/src/app.rs +++ b/crates/perry-ui-macos/src/app.rs @@ -52,6 +52,7 @@ pub(crate) struct AppEntry { /// Issue #1280 — initial window state applied on `app_run`. `zoom:` / /// `toggleFullScreen:` need the window to be key+ordered front first. pub(crate) window_state: Option, + frame_autosave_name: Option, } /// Issue #1280 — initial window state for the main app window. @@ -120,6 +121,7 @@ pub fn app_create(title_ptr: *const u8, width: f64, height: f64) -> i64 { window, _root_widget: None, window_state: None, + frame_autosave_name: None, }); apps.len() as i64 // 1-based handle }) @@ -445,7 +447,17 @@ pub fn app_run(_app_handle: i64) { APPS.with(|a| { let apps = a.borrow(); for entry in apps.iter() { - entry.window.center(); + // Restore after body/style configuration and before showing. Centering + // unconditionally here used to discard the saved position (#10170). + let restored = entry.frame_autosave_name.as_ref().is_some_and(|name| { + let name = NSString::from_str(name); + let restored = entry.window.setFrameUsingName(&name); + let _ = entry.window.setFrameAutosaveName(&name); + restored + }); + if !restored { + entry.window.center(); + } // Validate window is on a visible screen — if the position was // restored from a previous session with a different display setup, @@ -502,7 +514,7 @@ pub fn app_run(_app_handle: i64) { // toggleFullScreen: path enters native fullscreen on its own // Space. Both need the window to be key+ordered front, which is // why this runs here rather than in the setter. - if let Some(state) = entry.window_state { + if let Some(state) = entry.window_state.filter(|_| !restored) { unsafe { match state { WindowState::Maximized => { @@ -698,6 +710,20 @@ pub fn set_max_size(app_handle: i64, w: f64, h: f64) { }); } +/// Record the stable name; restore after window setup and before presentation. +pub fn set_frame_autosave_name(app_handle: i64, value_ptr: *const u8) { + let name = unsafe { str_from_header(value_ptr) }; + let key = perry_ui::frame::autosave_key(&name); + APPS.with(|apps| { + if let Some(entry) = apps + .borrow_mut() + .get_mut(app_handle.saturating_sub(1) as usize) + { + entry.frame_autosave_name = key; + } + }); +} + /// Issue #1280 — record the requested initial window state. Applied in /// `app_run` after the window is key+ordered front (zoom: / toggleFullScreen: /// don't take effect on a window that hasn't been shown yet). diff --git a/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs b/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs index 4dad8178bb..6107210f34 100644 --- a/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs +++ b/crates/perry-ui-macos/src/lib_ffi/core_widgets.rs @@ -532,3 +532,9 @@ pub extern "C" fn perry_ui_button_set_content_tint_color( pub extern "C" fn perry_ui_button_set_image_position(handle: i64, position: i64) { widgets::button::set_image_position(handle, position); } + +/// Opt in to desktop window frame persistence with an application-local key. +#[no_mangle] +pub extern "C" fn perry_ui_app_set_frame_autosave_name(app_handle: i64, value_ptr: i64) { + app::set_frame_autosave_name(app_handle, value_ptr as *const u8); +} diff --git a/crates/perry-ui-tvos/src/ffi/app_keychain.rs b/crates/perry-ui-tvos/src/ffi/app_keychain.rs index b4bea08c2b..e7fc4b8ff0 100644 --- a/crates/perry-ui-tvos/src/ffi/app_keychain.rs +++ b/crates/perry-ui-tvos/src/ffi/app_keychain.rs @@ -163,3 +163,7 @@ pub extern "C" fn perry_system_keychain_delete(key_ptr: i64) { SecItemDelete(&*query as *const _ as *const std::ffi::c_void); } } + +/// Frame persistence only applies to repositionable desktop windows. +#[no_mangle] +pub extern "C" fn perry_ui_app_set_frame_autosave_name(_app_handle: i64, _value_ptr: i64) {} diff --git a/crates/perry-ui-visionos/src/ffi_system.rs b/crates/perry-ui-visionos/src/ffi_system.rs index 24e0d276d9..7d1a98127c 100644 --- a/crates/perry-ui-visionos/src/ffi_system.rs +++ b/crates/perry-ui-visionos/src/ffi_system.rs @@ -690,3 +690,7 @@ pub extern "C" fn perry_ui_toolbar_add_item( #[no_mangle] pub extern "C" fn perry_ui_toolbar_attach(_toolbar: i64) {} + +/// Frame persistence only applies to repositionable desktop windows. +#[no_mangle] +pub extern "C" fn perry_ui_app_set_frame_autosave_name(_app_handle: i64, _value_ptr: i64) {} diff --git a/crates/perry-ui-watchos/src/lib.rs b/crates/perry-ui-watchos/src/lib.rs index 8b3dbc3e0d..f17bd64ad6 100644 --- a/crates/perry-ui-watchos/src/lib.rs +++ b/crates/perry-ui-watchos/src/lib.rs @@ -1960,3 +1960,7 @@ pub extern "C" fn perry_ui_canvas_draw_image( _dh: f64, ) { } + +/// Frame persistence only applies to repositionable desktop windows. +#[no_mangle] +pub extern "C" fn perry_ui_app_set_frame_autosave_name(_app_handle: i64, _value_ptr: i64) {} diff --git a/crates/perry-ui-windows-winui/src/app.rs b/crates/perry-ui-windows-winui/src/app.rs index 49963528d3..8c18fb45f2 100644 --- a/crates/perry-ui-windows-winui/src/app.rs +++ b/crates/perry-ui-windows-winui/src/app.rs @@ -3,6 +3,7 @@ use std::cell::RefCell; use std::time::Duration; +use perry_ui_windows::frame_persistence::{self, FrameStore, WindowState}; use windows::Win32::Foundation::HWND; use windows_reactor::winui::host::PresenterKind; use windows_reactor::{App, Backdrop, DispatcherTimer, InnerConstraints}; @@ -27,6 +28,8 @@ struct AppState { min_size: Option<(f64, f64)>, max_size: Option<(f64, f64)>, presenter: PresenterKind, + maximized: bool, + frame_autosave_name: Option, } thread_local! { @@ -168,6 +171,39 @@ pub fn app_run(app_handle: i64) { .inner_constraints(constraints) .presenter(state.presenter) .backdrop(Backdrop::Mica); + let store = state + .frame_autosave_name + .as_deref() + .and_then(FrameStore::new); + if store.is_some() || state.maximized { + app = app.on_window_created(move |host| { + let Ok(handle) = host.window_handle() else { + return; + }; + let hwnd = HWND(handle as *mut _); + let fallback = if state.presenter == PresenterKind::FullScreen { + WindowState::Fullscreen + } else if state.maximized { + WindowState::Maximized + } else { + WindowState::Normal + }; + let restored = store.map_or(fallback, |store| { + frame_persistence::install(hwnd, store, fallback) + }); + host.set_presenter(if restored == WindowState::Fullscreen { + PresenterKind::FullScreen + } else { + PresenterKind::Default + }); + if restored == WindowState::Maximized { + use windows::Win32::UI::WindowsAndMessaging::{ShowWindow, SW_SHOWMAXIMIZED}; + unsafe { + let _ = ShowWindow(hwnd, SW_SHOWMAXIMIZED); + } + } + }); + } if app_callback(&ON_TERMINATE) != 0 { app = app.on_exit(move || invoke_app_callback(&ON_TERMINATE)); } @@ -230,7 +266,21 @@ pub fn set_window_state(app_handle: i64, value_ptr: *const u8) { } else { PresenterKind::Default }; - with_app_mut(app_handle, |app| app.presenter = presenter); + with_app_mut(app_handle, |app| { + app.presenter = presenter; + app.maximized = value.eq_ignore_ascii_case("maximized"); + }); +} + +pub fn set_frame_autosave_name(app_handle: i64, value_ptr: *const u8) { + if !is_fluent() { + perry_ui_windows::app::set_frame_autosave_name(app_handle, value_ptr); + return; + } + let name = unsafe { perry_ffi::copy_string_from_raw(value_ptr) }; + with_app_mut(app_handle, |app| { + app.frame_autosave_name = (!name.is_empty()).then_some(name); + }); } pub fn set_timer(interval_ms: f64, callback: f64) { diff --git a/crates/perry-ui-windows/src/app.rs b/crates/perry-ui-windows/src/app.rs index 51792a6444..bc45b50132 100644 --- a/crates/perry-ui-windows/src/app.rs +++ b/crates/perry-ui-windows/src/app.rs @@ -337,6 +337,7 @@ pub(crate) struct AppEntry { /// Issue #1280 — initial window state requested by App({ windowState }). /// Applied at app_run time; None / "normal" => SW_SHOW. window_state: Option, + frame_autosave_name: Option, /// Activation policy ("regular" | "accessory" | "background"). For /// accessory/background apps the launch window is suppressed (the tray /// owns presentation), mirroring the macOS/GTK4 backends. @@ -516,6 +517,7 @@ pub fn app_create(title_ptr: *const u8, width: f64, height: f64) -> i64 { min_size: None, max_size: None, window_state: None, + frame_autosave_name: None, activation_policy: PENDING_ACTIVATION_POLICY.with(|p| p.borrow().clone()), }); apps.len() as i64 @@ -534,6 +536,7 @@ pub fn app_create(title_ptr: *const u8, width: f64, height: f64) -> i64 { min_size: None, max_size: None, window_state: None, + frame_autosave_name: None, activation_policy: PENDING_ACTIVATION_POLICY.with(|p| p.borrow().clone()), }); apps.len() as i64 @@ -629,71 +632,93 @@ pub fn app_run(app_handle: i64) { #[cfg(target_os = "windows")] { - APPS.with(|apps| { + // Native placement/show calls synchronously dispatch messages. Release + // the app table borrow first so callbacks can read or mutate app state. + let config = APPS.with(|apps| { let apps = apps.borrow(); - let idx = (app_handle - 1) as usize; - if idx < apps.len() { - let hwnd = apps[idx].hwnd; - let state = apps[idx].window_state; - // Accessory/background apps stay window-less at launch (the - // tray owns presentation); open windows on demand instead. - // Mirrors the macOS/GTK4 backends. - let suppress_window = matches!( - apps[idx].activation_policy.as_deref(), - Some("accessory") | Some("background") - ); - // Pre-App() `appSetActivationPolicy` couldn't apply the - // taskbar style (the hwnd didn't exist yet); do it here. - if suppress_window { - unsafe { - let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE) as u32; - let new_style = (ex_style & !WS_EX_APPWINDOW.0) | WS_EX_TOOLWINDOW.0; - SetWindowLongW(hwnd, GWL_EXSTYLE, new_style as i32); - } + let entry = apps.get(app_handle.saturating_sub(1) as usize)?; + Some(( + entry.hwnd, + entry.window_state, + entry.activation_policy.clone(), + entry.frame_autosave_name.clone(), + )) + }); + if let Some((hwnd, mut state, activation_policy, frame_autosave_name)) = config { + if let Some(store) = frame_autosave_name + .as_deref() + .and_then(crate::frame_persistence::FrameStore::new) + { + use crate::frame_persistence::WindowState as SavedState; + let fallback = match state { + Some(WindowState::Maximized) => SavedState::Maximized, + Some(WindowState::Fullscreen) => SavedState::Fullscreen, + None => SavedState::Normal, + }; + state = match crate::frame_persistence::install(hwnd, store, fallback) { + SavedState::Maximized => Some(WindowState::Maximized), + SavedState::Fullscreen => Some(WindowState::Fullscreen), + SavedState::Normal => None, + }; + } + // Accessory/background apps stay window-less at launch (the + // tray owns presentation); open windows on demand instead. + // Mirrors the macOS/GTK4 backends. + let suppress_window = matches!( + activation_policy.as_deref(), + Some("accessory") | Some("background") + ); + // Pre-App() `appSetActivationPolicy` couldn't apply the + // taskbar style (the hwnd didn't exist yet); do it here. + if suppress_window { + unsafe { + let ex_style = GetWindowLongW(hwnd, GWL_EXSTYLE) as u32; + let new_style = (ex_style & !WS_EX_APPWINDOW.0) | WS_EX_TOOLWINDOW.0; + SetWindowLongW(hwnd, GWL_EXSTYLE, new_style as i32); } - if !suppress_window { - unsafe { - // Issue #1280 — apply requested initial window state. - // Fullscreen on Win32 = drop WS_OVERLAPPEDWINDOW frame and - // resize to the monitor's full rect (not just the work - // area, which excludes the taskbar). Maximized = standard - // SW_SHOWMAXIMIZED which respects the taskbar. - match state { - Some(WindowState::Fullscreen) => { - let style = GetWindowLongW(hwnd, GWL_STYLE) as u32; - let new_style = style & !WS_OVERLAPPEDWINDOW.0; - SetWindowLongW(hwnd, GWL_STYLE, new_style as i32); - let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); - let mut mi = MONITORINFO { - cbSize: std::mem::size_of::() as u32, - ..Default::default() - }; - if GetMonitorInfoW(monitor, &mut mi).as_bool() { - let r = mi.rcMonitor; - let _ = SetWindowPos( - hwnd, - Some(HWND_TOP), - r.left, - r.top, - r.right - r.left, - r.bottom - r.top, - SWP_NOOWNERZORDER | SWP_FRAMECHANGED, - ); - } - let _ = ShowWindow(hwnd, SW_SHOW); - } - Some(WindowState::Maximized) => { - let _ = ShowWindow(hwnd, SW_SHOWMAXIMIZED); - } - None => { - let _ = ShowWindow(hwnd, SW_SHOW); + } + if !suppress_window { + unsafe { + // Issue #1280 — apply requested initial window state. + // Fullscreen on Win32 = drop WS_OVERLAPPEDWINDOW frame and + // resize to the monitor's full rect (not just the work + // area, which excludes the taskbar). Maximized = standard + // SW_SHOWMAXIMIZED which respects the taskbar. + match state { + Some(WindowState::Fullscreen) => { + let style = GetWindowLongW(hwnd, GWL_STYLE) as u32; + let new_style = style & !WS_OVERLAPPEDWINDOW.0; + SetWindowLongW(hwnd, GWL_STYLE, new_style as i32); + let monitor = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); + let mut mi = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + if GetMonitorInfoW(monitor, &mut mi).as_bool() { + let r = mi.rcMonitor; + let _ = SetWindowPos( + hwnd, + Some(HWND_TOP), + r.left, + r.top, + r.right - r.left, + r.bottom - r.top, + SWP_NOOWNERZORDER | SWP_FRAMECHANGED, + ); } + let _ = ShowWindow(hwnd, SW_SHOW); + } + Some(WindowState::Maximized) => { + let _ = ShowWindow(hwnd, SW_SHOWMAXIMIZED); + } + None => { + let _ = ShowWindow(hwnd, SW_SHOW); } - let _ = UpdateWindow(hwnd); } + let _ = UpdateWindow(hwnd); } } - }); + } // PERRY_UI_TEST_MODE: schedule a one-shot exit timer so CI can verify // that the app launched without a human keeping it open. @@ -1016,6 +1041,19 @@ pub fn set_max_size(app_handle: i64, w: f64, h: f64) { }); } +/// Record the stable name; restore after window setup and before presentation. +pub fn set_frame_autosave_name(app_handle: i64, value_ptr: *const u8) { + let name = unsafe { str_from_header(value_ptr) }; + APPS.with(|apps| { + if let Some(entry) = apps + .borrow_mut() + .get_mut(app_handle.saturating_sub(1) as usize) + { + entry.frame_autosave_name = (!name.is_empty()).then_some(name); + } + }); +} + /// Issue #1280 — record the requested initial window state. The state is /// applied in `app_run` (Win32 needs the window to exist + be ready before /// `ShowWindow(SW_SHOWMAXIMIZED)` or the fullscreen frame swap takes effect). diff --git a/crates/perry-ui-windows/src/ffi/app_window.rs b/crates/perry-ui-windows/src/ffi/app_window.rs index ba8bbc968e..52040a5dce 100644 --- a/crates/perry-ui-windows/src/ffi/app_window.rs +++ b/crates/perry-ui-windows/src/ffi/app_window.rs @@ -111,3 +111,9 @@ pub extern "C" fn perry_ui_window_set_size(window_handle: i64, width: f64, heigh pub extern "C" fn perry_ui_window_on_focus_lost(window_handle: i64, callback: f64) { window::on_focus_lost(window_handle, callback); } + +/// Opt in to desktop window frame persistence with an application-local key. +#[no_mangle] +pub extern "C" fn perry_ui_app_set_frame_autosave_name(app_handle: i64, value_ptr: i64) { + app::set_frame_autosave_name(app_handle, value_ptr as *const u8); +} diff --git a/crates/perry-ui-windows/src/frame_persistence.rs b/crates/perry-ui-windows/src/frame_persistence.rs new file mode 100644 index 0000000000..72d6e4c1f7 --- /dev/null +++ b/crates/perry-ui-windows/src/frame_persistence.rs @@ -0,0 +1,289 @@ +//! Native placement persistence shared by Win32 and WinUI windows. + +use std::cell::RefCell; +use std::collections::HashMap; + +use perry_ui::frame::WindowFrame; +pub use perry_ui::frame::{FrameStore, WindowState}; +use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, RECT, WPARAM}; +use windows::Win32::UI::Shell::{DefSubclassProc, RemoveWindowSubclass, SetWindowSubclass}; +use windows::Win32::UI::WindowsAndMessaging::*; + +const SUBCLASS_ID: usize = 10170; + +struct SavedWindow { + store: FrameStore, + frame: WindowFrame, + fullscreen: bool, +} + +thread_local! { + static WINDOWS: RefCell> = RefCell::new(HashMap::new()); +} + +fn placement(hwnd: HWND) -> Option { + let mut placement = WINDOWPLACEMENT { + length: std::mem::size_of::() as u32, + ..Default::default() + }; + unsafe { + GetWindowPlacement(hwnd, &mut placement).ok()?; + } + Some(placement) +} + +fn frame_from_placement(value: &WINDOWPLACEMENT, previous: WindowState) -> Option { + let rect = value.rcNormalPosition; + Some(WindowFrame { + x: rect.left, + y: rect.top, + width: rect.right.checked_sub(rect.left)?, + height: rect.bottom.checked_sub(rect.top)?, + // Never reopen minimized. Preserve the last non-minimized state, + // including a maximized window that was minimized before quitting. + state: match SHOW_WINDOW_CMD(value.showCmd as i32) { + SW_SHOWMAXIMIZED => WindowState::Maximized, + SW_SHOWMINIMIZED | SW_MINIMIZE | SW_SHOWMINNOACTIVE => previous, + _ => WindowState::Normal, + }, + }) +} + +/// Restore while hidden, then observe placement changes. Returns the saved +/// state (if any); the caller applies it when showing the window. +pub fn install(hwnd: HWND, store: FrameStore, fallback: WindowState) -> WindowState { + let saved = store.load(); + let mut restored = None; + if let Some(frame) = saved { + let placement = WINDOWPLACEMENT { + length: std::mem::size_of::() as u32, + showCmd: SW_HIDE.0 as u32, + rcNormalPosition: RECT { + left: frame.x, + top: frame.y, + right: frame.x + frame.width, + bottom: frame.y + frame.height, + }, + ..Default::default() + }; + // SetWindowPlacement uses workspace coordinates and brings a frame + // back onto an available monitor after the display setup changes. + if unsafe { SetWindowPlacement(hwnd, &placement) }.is_ok() { + restored = Some(frame); + } + } + let state = restored.map_or(fallback, |frame| frame.state); + let normal_frame = placement(hwnd).and_then(|value| frame_from_placement(&value, state)); + if let Some(mut frame) = normal_frame { + frame.state = state; + WINDOWS.with(|windows| { + windows.borrow_mut().insert( + hwnd.0 as isize, + SavedWindow { + store, + frame, + fullscreen: state == WindowState::Fullscreen, + }, + ); + }); + if !unsafe { SetWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID, 0) }.as_bool() { + WINDOWS.with(|windows| windows.borrow_mut().remove(&(hwnd.0 as isize))); + } + } + state +} + +fn update(hwnd: HWND, persist: bool) { + let current = placement(hwnd); + WINDOWS.with(|windows| { + let mut windows = windows.borrow_mut(); + let Some(saved) = windows.get_mut(&(hwnd.0 as isize)) else { + return; + }; + // Win32 fullscreen replaces the normal rect with the monitor rect. + // Keep the pre-fullscreen placement so restore-down geometry survives. + if !saved.fullscreen { + if let Some(frame) = + current.and_then(|value| frame_from_placement(&value, saved.frame.state)) + { + saved.frame = frame; + } + } + if persist { + let _ = saved.store.save(saved.frame); + } + }); +} + +unsafe extern "system" fn subclass_proc( + hwnd: HWND, + message: u32, + wparam: WPARAM, + lparam: LPARAM, + _id: usize, + _data: usize, +) -> LRESULT { + // WinUI's Closed callback exits the process, so save before forwarding + // WM_CLOSE. WM_QUERYENDSESSION also covers an OS logout/shutdown. + if matches!( + message, + WM_CLOSE | WM_QUERYENDSESSION | WM_DESTROY | WM_EXITSIZEMOVE + ) { + update(hwnd, true); + } + if message == WM_NCDESTROY { + WINDOWS.with(|windows| windows.borrow_mut().remove(&(hwnd.0 as isize))); + let _ = RemoveWindowSubclass(hwnd, Some(subclass_proc), SUBCLASS_ID); + } + let result = DefSubclassProc(hwnd, message, wparam, lparam); + if message == WM_SIZE { + update(hwnd, false); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_close_and_reopen_restores_placement_before_showing() { + struct Fixture { + directory: std::path::PathBuf, + hwnd: HWND, + } + impl Fixture { + fn window(&mut self) -> HWND { + self.hwnd = unsafe { + CreateWindowExW( + WINDOW_EX_STYLE::default(), + windows::core::w!("STATIC"), + windows::core::w!("Perry frame persistence test"), + WS_OVERLAPPEDWINDOW, + 200, + 200, + 800, + 600, + None, + None, + None, + None, + ) + .unwrap() + }; + self.hwnd + } + fn close(&mut self) { + unsafe { + SendMessageW(self.hwnd, WM_CLOSE, None, None); + } + self.hwnd = HWND::default(); + } + } + impl Drop for Fixture { + fn drop(&mut self) { + if !self.hwnd.0.is_null() { + unsafe { + let _ = DestroyWindow(self.hwnd); + } + } + let key = perry_ui::frame::autosave_key("main").unwrap(); + let _ = std::fs::remove_file(self.directory.join(key)); + let _ = std::fs::remove_dir(&self.directory); + } + } + let directory = std::env::temp_dir().join(format!( + "perry-frame-test-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let mut fixture = Fixture { + directory, + hwnd: HWND::default(), + }; + let store = FrameStore::in_directory(&fixture.directory, "main").unwrap(); + let hwnd = fixture.window(); + assert_eq!( + install(hwnd, store.clone(), WindowState::Normal), + WindowState::Normal + ); + assert_eq!( + store.load(), + None, + "opting in must not overwrite preferences during setup" + ); + unsafe { + SetWindowPos(hwnd, None, 120, 90, 640, 480, SWP_NOZORDER | SWP_NOACTIVATE).unwrap(); + } + let expected = + frame_from_placement(&placement(hwnd).unwrap(), WindowState::Normal).unwrap(); + fixture.close(); + assert_eq!(store.load(), Some(expected)); + + let hwnd = fixture.window(); + assert_eq!( + install(hwnd, store.clone(), WindowState::Maximized), + WindowState::Normal, + "saved normal state takes precedence over a maximized launch default" + ); + assert!( + !unsafe { IsWindowVisible(hwnd) }.as_bool(), + "restoring must not show the window" + ); + assert_eq!( + frame_from_placement(&placement(hwnd).unwrap(), WindowState::Normal), + Some(expected) + ); + fixture.close(); + + let fullscreen = WindowFrame { + state: WindowState::Fullscreen, + ..expected + }; + store.save(fullscreen).unwrap(); + let hwnd = fixture.window(); + assert_eq!( + install(hwnd, store.clone(), WindowState::Normal), + WindowState::Fullscreen + ); + // Mimic the fullscreen resize. It must not replace the normal frame. + unsafe { + SetWindowPos(hwnd, None, 0, 0, 1920, 1080, SWP_NOZORDER | SWP_NOACTIVATE).unwrap(); + } + fixture.close(); + assert_eq!(store.load(), Some(fullscreen)); + } + + #[test] + fn minimized_windows_keep_their_normal_frame_and_previous_state() { + let mut value = WINDOWPLACEMENT { + rcNormalPosition: RECT { + left: -900, + top: 40, + right: -100, + bottom: 640, + }, + showCmd: SW_SHOWMAXIMIZED.0 as u32, + ..Default::default() + }; + let maximized = frame_from_placement(&value, WindowState::Normal).unwrap(); + assert_eq!(maximized.state, WindowState::Maximized); + assert_eq!( + (maximized.x, maximized.width, maximized.height), + (-900, 800, 600) + ); + value.showCmd = SW_SHOWMINIMIZED.0 as u32; + assert_eq!( + frame_from_placement(&value, maximized.state), + Some(maximized) + ); + value.showCmd = SW_SHOWNORMAL.0 as u32; + assert_eq!( + frame_from_placement(&value, maximized.state).unwrap().state, + WindowState::Normal + ); + } +} diff --git a/crates/perry-ui-windows/src/lib.rs b/crates/perry-ui-windows/src/lib.rs index 0d2fcf825d..2a5f0e1c50 100644 --- a/crates/perry-ui-windows/src/lib.rs +++ b/crates/perry-ui-windows/src/lib.rs @@ -9,6 +9,7 @@ pub mod dpi_compat; pub mod drag_drop; #[cfg(target_os = "windows")] pub mod dwm; +pub mod frame_persistence; // `pub` so the opt-in WinUI backend (`perry-ui-windows-winui`) can chain this // crate's scanner. On the Fluent path that crate shadows `app_create`, so // `app::app_create` below never runs and would otherwise never register it, diff --git a/crates/perry-ui/Cargo.toml b/crates/perry-ui/Cargo.toml index c84fb014f7..76dbe2d853 100644 --- a/crates/perry-ui/Cargo.toml +++ b/crates/perry-ui/Cargo.toml @@ -13,3 +13,7 @@ crate-type = ["rlib"] [dependencies] perry-ffi.workspace = true perry-ui-model.workspace = true + +[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] +dirs.workspace = true +tempfile.workspace = true diff --git a/crates/perry-ui/src/frame.rs b/crates/perry-ui/src/frame.rs new file mode 100644 index 0000000000..d99ea52875 --- /dev/null +++ b/crates/perry-ui/src/frame.rs @@ -0,0 +1,214 @@ +//! Opt-in desktop window persistence, scoped to the executable and window name. + +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum WindowState { + #[default] + Normal, + Maximized, + Fullscreen, +} + +/// The normal (unmaximized, non-fullscreen) frame, in backend coordinates. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct WindowFrame { + pub x: i32, + pub y: i32, + pub width: i32, + pub height: i32, + pub state: WindowState, +} + +impl WindowFrame { + fn valid(self) -> bool { + self.width > 0 + && self.height > 0 + && self.x.checked_add(self.width).is_some() + && self.y.checked_add(self.height).is_some() + } + + fn decode(value: &str) -> Option { + let fields: Vec<_> = value.split_whitespace().collect(); + if fields.len() != 7 || fields[0] != "1" { + return None; + } + let frame = Self { + x: fields[1].parse().ok()?, + y: fields[2].parse().ok()?, + width: fields[3].parse().ok()?, + height: fields[4].parse().ok()?, + state: match fields[5] { + "normal" => WindowState::Normal, + "maximized" => WindowState::Maximized, + "fullscreen" => WindowState::Fullscreen, + _ => return None, + }, + }; + // A terminator detects truncated writes, including a partial state name. + (fields[6] == "end" && frame.valid()).then_some(frame) + } +} + +// FNV-1a is explicitly fixed so persistence keys survive Rust upgrades. +// The digest is only a filename component, not a security boundary. +fn digest(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf29ce484222325, |hash, byte| { + (hash ^ u64::from(*byte)).wrapping_mul(0x100000001b3) + }) +} + +fn key_for(executable: &Path, name: &str) -> Option { + if name.is_empty() { + return None; + } + Some(format!( + "perry-frame-{:016x}-{:016x}", + digest(executable.as_os_str().as_encoded_bytes()), + digest(name.as_bytes()) + )) +} + +/// Also used for AppKit's native frame autosave name. Do not key by title: +/// titles can change, and unrelated command-line apps share NSUserDefaults. +pub fn autosave_key(name: &str) -> Option { + key_for(&std::env::current_exe().ok()?, name) +} + +#[derive(Clone, Debug)] +pub struct FrameStore { + path: PathBuf, +} + +impl FrameStore { + pub fn new(name: &str) -> Option { + Self::in_directory( + &dirs::data_local_dir()?.join("perry").join("window-frames"), + name, + ) + } + + /// Use an explicit preferences directory (also useful for isolated tests). + pub fn in_directory(directory: &Path, name: &str) -> Option { + Some(Self { + path: directory.join(autosave_key(name)?), + }) + } + + /// Missing, invalid, or unreadable preferences leave launch defaults intact. + pub fn load(&self) -> Option { + let mut value = String::new(); + std::fs::File::open(&self.path) + .ok()? + .take(257) + .read_to_string(&mut value) + .ok()?; + if value.len() > 256 { + return None; + } + WindowFrame::decode(&value) + } + + /// Replace atomically, including on Windows. A failed write must not + /// truncate the previous frame or prevent the application from closing. + pub fn save(&self, frame: WindowFrame) -> std::io::Result<()> { + if !frame.valid() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "invalid window frame", + )); + } + let parent = self.path.parent().expect("frame store has a parent"); + std::fs::create_dir_all(parent)?; + let mut file = tempfile::NamedTempFile::new_in(parent)?; + let state = match frame.state { + WindowState::Normal => "normal", + WindowState::Maximized => "maximized", + WindowState::Fullscreen => "fullscreen", + }; + writeln!( + file, + "1 {} {} {} {} {state} end", + frame.x, frame.y, frame.width, frame.height + )?; + file.persist(&self.path).map_err(|error| error.error)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn names_are_stable_isolated_and_cannot_escape_the_store() { + let exe = Path::new("/apps/editor"); + assert_eq!(digest(b"hello"), 0xa430d84680aabd0b); + assert_eq!(key_for(exe, ""), None); + assert_eq!(key_for(exe, "main"), key_for(exe, "main")); + assert_ne!(key_for(exe, "main"), key_for(exe, "settings")); + assert_ne!( + key_for(exe, "main"), + key_for(Path::new("/apps/other"), "main") + ); + let key = key_for(exe, "../../settings/窗口").unwrap(); + assert_eq!(Path::new(&key).components().count(), 1); + } + + #[test] + fn saves_replace_previous_frames_and_survive_reopening() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("app").join("main"); + let store = FrameStore { path: path.clone() }; + assert_eq!(store.load(), None); + for state in [ + WindowState::Normal, + WindowState::Maximized, + WindowState::Fullscreen, + ] { + let frame = WindowFrame { + x: -1200, + y: 40, + width: 900, + height: 650, + state, + }; + store.save(frame).unwrap(); + assert_eq!(FrameStore { path: path.clone() }.load(), Some(frame)); + } + } + + #[test] + fn invalid_preferences_fall_back_without_destroying_a_saved_frame() { + for value in [ + "", + "2 0 0 800 600 normal end", + "1 0 0 800 600 normal", + "1 0 0 0 600 normal end", + "1 0 0 800 -600 normal end", + "1 0 0 800 600 minimized end", + "1 2147483647 0 800 600 normal end", + "1 0 0 NaN 600 normal end", + "1 0 0 800 600 normal end trailing", + ] { + assert_eq!(WindowFrame::decode(value), None, "{value}"); + } + let dir = tempfile::tempdir().unwrap(); + let store = FrameStore { + path: dir.path().join("main"), + }; + let frame = WindowFrame { + x: 10, + y: 20, + width: 800, + height: 600, + state: WindowState::Normal, + }; + store.save(frame).unwrap(); + assert!(store.save(WindowFrame { width: 0, ..frame }).is_err()); + assert_eq!(store.load(), Some(frame)); + std::fs::write(&store.path, "invalid").unwrap(); + assert_eq!(store.load(), None); + } +} diff --git a/crates/perry-ui/src/lib.rs b/crates/perry-ui/src/lib.rs index 81fbc758f4..bb548e597d 100644 --- a/crates/perry-ui/src/lib.rs +++ b/crates/perry-ui/src/lib.rs @@ -1,3 +1,5 @@ +#[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] +pub mod frame; pub mod key_dispatch; pub mod keys; pub mod state; diff --git a/docs/examples/ui/overview/frame-persistence.ts b/docs/examples/ui/overview/frame-persistence.ts new file mode 100644 index 0000000000..cd99806c2c --- /dev/null +++ b/docs/examples/ui/overview/frame-persistence.ts @@ -0,0 +1,14 @@ +// demonstrates: opt-in desktop window frame persistence across launches +// docs: docs/src/ui/overview.md +// platforms: macos, linux, windows + +import { App, Text } from "perry/ui" + +App({ + title: "Remember my window", + width: 800, + height: 600, + windowState: "normal", + frameAutosaveName: "main", + body: Text("Move or resize this window, close it, and launch again."), +}) diff --git a/docs/src/ui/overview.md b/docs/src/ui/overview.md index 113f1f13c1..990ae0a834 100644 --- a/docs/src/ui/overview.md +++ b/docs/src/ui/overview.md @@ -39,6 +39,7 @@ Every Perry UI app starts with `App()`: | `body` | widget | Root widget | | `icon` | string | App icon file path (optional) | | `windowState` | string | Initial state: `"normal"`, `"maximized"`, `"fullscreen"` (optional) | +| `frameAutosaveName` | string | Remember the desktop window frame under a stable name (optional; empty disables) | | `frameless` | boolean | Remove title bar (optional) | | `level` | string | Window z-order: `"floating"`, `"statusBar"`, `"modal"` (optional) | | `transparent` | boolean | Transparent background (optional) | @@ -49,6 +50,29 @@ See [Multi-Window](multi-window.md#app-window-properties) for full documentation on window properties, including the native primitive each field maps to per platform. +### Remembering the window frame + +Set `frameAutosaveName` to a stable name to reopen an app where the user left it: + +```typescript +{{#include ../../examples/ui/overview/frame-persistence.ts}} +``` + +The saved frame takes precedence over `width`, `height`, and `windowState`. +Missing or unreadable saved settings use those launch defaults. Omit the option +or pass an empty string to disable persistence. Names are scoped to the executable +path, so unrelated apps do not share frames; moving the executable starts fresh. +Use a different name for each independent app window. Changing the title does not +change the saved frame. + +macOS uses AppKit's native frame autosave for position and size; this option does +not promise restoration of a fullscreen Space. Windows, including WinUI, saves +normal placement and maximized/fullscreen state, and reopens minimized windows +in their previous non-minimized state. GTK4 saves normal size and +maximized/fullscreen state; the compositor controls window position because +[GTK4 has no global positioning API](https://docs.gtk.org/gtk4/migrating-3to4.html). +The option is ignored on mobile, TV, watch, and visionOS backends. + ### Lifecycle Hooks ```typescript diff --git a/third_party/windows-winui/VENDORED.md b/third_party/windows-winui/VENDORED.md index 1d1414f5da..28f1c8a692 100644 --- a/third_party/windows-winui/VENDORED.md +++ b/third_party/windows-winui/VENDORED.md @@ -13,6 +13,8 @@ bootstrap DLL, its import library, and the XAML resource PRI in Perry's Cargo target directory. Perry also adds an application-exit callback hook so its existing lifecycle ABI can run termination handlers when the WinUI window closes. +Perry also adds a window-created hook and native handle accessor so desktop +frame persistence can restore placement before the window is activated. The upstream MIT and Apache-2.0 license files are preserved in every vendored crate directory. diff --git a/third_party/windows-winui/windows-reactor/src/app.rs b/third_party/windows-winui/windows-reactor/src/app.rs index f6ec23447f..6aa1b1ed4c 100644 --- a/third_party/windows-winui/windows-reactor/src/app.rs +++ b/third_party/windows-winui/windows-reactor/src/app.rs @@ -36,6 +36,7 @@ pub struct App { presenter: PresenterKind, backdrop: Option, on_exit: Option>, + on_window_created: Option>, } impl Default for App { @@ -54,6 +55,7 @@ impl App { presenter: PresenterKind::Default, backdrop: None, on_exit: None, + on_window_created: None, } } @@ -104,6 +106,15 @@ impl App { self } + /// Configure native placement after layout setup and before activation. + pub fn on_window_created( + mut self, + callback: impl FnOnce(&ReactorHost) + Send + 'static, + ) -> Self { + self.on_window_created = Some(Box::new(callback)); + self + } + /// Run with custom WinUI setup; the caller manages windows and content. pub fn run_custom(self, setup: F) -> Result<()> where @@ -156,6 +167,7 @@ impl App { let presenter = self.presenter; let backdrop = self.backdrop; let on_exit = Arc::new(Mutex::new(self.on_exit)); + let on_window_created = Mutex::new(self.on_window_created); let factory = Mutex::new(Some(root_factory)); let result_slot: Arc>> = Arc::new(Mutex::new(Ok(()))); let result_slot_cb = Arc::clone(&result_slot); @@ -164,6 +176,7 @@ impl App { let on_exit = Arc::clone(&on_exit); let inner = || -> Result<()> { let factory = factory.lock().unwrap().take().unwrap(); + let on_window_created = on_window_created.lock().unwrap().take(); let title = title.clone(); let on_launched: Box Result<()>> = Box::new(move || { @@ -185,6 +198,9 @@ impl App { if let Some(bd) = backdrop { host.set_backdrop(bd); } + if let Some(callback) = on_window_created { + callback(&host); + } host.activate()?; // Exit the process on window close. Application.Exit() // fail-fasts due to live COM refs, so terminate directly. diff --git a/third_party/windows-winui/windows-reactor/src/winui/host.rs b/third_party/windows-winui/windows-reactor/src/winui/host.rs index 332540fd5e..a7344b7056 100644 --- a/third_party/windows-winui/windows-reactor/src/winui/host.rs +++ b/third_party/windows-winui/windows-reactor/src/winui/host.rs @@ -339,6 +339,16 @@ impl ReactorHost { &self.window } + /// Native handle for platform services such as window frame persistence. + pub fn window_handle(&self) -> windows_core::Result { + let native = self.window.cast::()?; + let mut hwnd = HWND::default(); + unsafe { + native.get_WindowHandle(&mut hwnd)?; + } + Ok(hwnd as isize) + } + pub fn stats(&self) -> RenderStats { self.render_host.stats() } diff --git a/types/perry/ui/index.d.ts b/types/perry/ui/index.d.ts index dbd30584d6..76d53639a3 100644 --- a/types/perry/ui/index.d.ts +++ b/types/perry/ui/index.d.ts @@ -239,6 +239,16 @@ export function App(config: { * Issue #1280. */ windowState?: "normal" | "maximized" | "fullscreen"; + /** + * Remember the desktop window frame under this stable name. Omit or use + * an empty string to disable. Names are scoped to the executable path; + * use different names for independent windows. Saved geometry takes + * precedence over width/height/windowState; those remain first-run defaults. + * macOS restores position/size via AppKit. Windows (including WinUI) also + * restores maximized/fullscreen state. GTK4 restores size and state; the + * compositor controls position. Ignored on mobile, TV, watch, and visionOS. + */ + frameAutosaveName?: string; /** * Remove the window title bar and frame (borderless window, movable * by background). v0.4.11 launcher-style option.