Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions changelog.d/10246-window-frame-persistence.md
Original file line number Diff line number Diff line change
@@ -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.
367 changes: 195 additions & 172 deletions crates/perry-codegen/src/lower_call/native/native_ui_appshell_branch.rs

Large diffs are not rendered by default.

34 changes: 33 additions & 1 deletion crates/perry-codegen/tests/app_window_config_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,12 +151,13 @@ fn compile_ir(name: &str, body: Vec<Stmt>) -> 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]
Expand All @@ -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 {
Expand All @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-ui-android/src/ffi/tabbar_layout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
23 changes: 21 additions & 2 deletions crates/perry-ui-gtk4/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ struct AppEntry {
activation_policy: Option<String>,
/// Issue #1280 — "maximized" | "fullscreen" | None (= "normal").
window_state: Option<String>,
frame_autosave_name: Option<String>,
}

extern "C" {
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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(),
_ => {}
Expand Down Expand Up @@ -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()`.
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-ui-gtk4/src/ffi/app_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
56 changes: 56 additions & 0 deletions crates/perry-ui-gtk4/src/frame_persistence.rs
Original file line number Diff line number Diff line change
@@ -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",
})
}
1 change: 1 addition & 0 deletions crates/perry-ui-gtk4/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-ui-ios/src/ffi/dialogs_lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
30 changes: 28 additions & 2 deletions crates/perry-ui-macos/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<WindowState>,
frame_autosave_name: Option<String>,
}

/// Issue #1280 — initial window state for the main app window.
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions crates/perry-ui-macos/src/lib_ffi/core_widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
4 changes: 4 additions & 0 deletions crates/perry-ui-tvos/src/ffi/app_keychain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
4 changes: 4 additions & 0 deletions crates/perry-ui-visionos/src/ffi_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
4 changes: 4 additions & 0 deletions crates/perry-ui-watchos/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}
Loading
Loading