diff --git a/desktop/app/qml/App.qml b/desktop/app/qml/App.qml index 4dbfb815..006427a8 100644 --- a/desktop/app/qml/App.qml +++ b/desktop/app/qml/App.qml @@ -12,9 +12,11 @@ QtObject { readonly property var mainWindow: mainLoader.item readonly property Loader mainLoader: Loader { + active: false + source: "qrc:/qt/qml/app/nectarlink/qml/MainWindow.qml" onLoaded: { item.closeRequested.connect(app.onMainClosed) - item.bringToFront() + item.restoreAndBringToFront() } } readonly property Loader laserLoader: Loader { @@ -118,14 +120,17 @@ QtObject { function showMain() { trayTrimTimer.stop() - if (!mainLoader.item) { - mainLoader.setSource("qrc:/qt/qml/app/nectarlink/qml/MainWindow.qml") + if (!mainLoader.active || !mainLoader.item) { + mainLoader.active = false + mainLoader.active = true } else { mainLoader.item.bringToFront() } } function onMainClosed() { + if (mainLoader.active && mainLoader.item) + mainLoader.item.saveWindowState() if (!Preferences.closeToTray) { Qt.quit() return @@ -134,7 +139,7 @@ QtObject { Qt.callLater(() => { if (mainLoader.item) mainLoader.item.releaseAndTeardown() - mainLoader.setSource("") + mainLoader.active = false trayTrimTimer.restart() }) } @@ -163,7 +168,11 @@ QtObject { readonly property Connections controller: Connections { target: AppController function onActivateRequested() { app.showMain() } - function onQuitRequested() { Qt.quit() } + function onQuitRequested() { + if (mainLoader.active && mainLoader.item) + mainLoader.item.saveWindowState() + Qt.quit() + } function onRingingFromChanged() { if (AppController.ringingFrom.length > 0) { app.showMain() diff --git a/desktop/app/qml/MainWindow.qml b/desktop/app/qml/MainWindow.qml index 5b3d4c97..589d880a 100644 --- a/desktop/app/qml/MainWindow.qml +++ b/desktop/app/qml/MainWindow.qml @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later import QtQuick +import QtQuick.Window import QtQuick.Effects import app.nectarlink @@ -108,19 +109,245 @@ NativeWindow { readonly property string currentDeviceName: DeviceList.count > 0 ? DeviceList.deviceNameAt(currentDeviceIndex) : "" - width: 1120 - height: 740 minimumWidth: Theme.layout.minWindow[0] minimumHeight: Theme.layout.minWindow[1] + property string normalScreen: Preferences.windowScreen + property int normalWidth: Math.max(minimumWidth, Preferences.windowWidth > 0 ? Preferences.windowWidth : 1120) + property int normalHeight: Math.max(minimumHeight, Preferences.windowHeight > 0 ? Preferences.windowHeight : 740) + property int normalX: Preferences.windowX + property int normalY: Preferences.windowY + property bool hasNormalPosition: Preferences.hasWindowPosition + property bool restoringGeometry: true + + width: normalWidth + height: normalHeight title: "NectarLink" + function screenKey(s) { + if (!s) return "" + const parts = [s.manufacturer || "", s.model || "", s.serialNumber || ""].filter(function(p) { return p.length > 0 }) + if (parts.length > 0) return parts.join(":") + return s.name || "" + } + + function findScreen(ident) { + const screens = Qt.application.screens + if (!ident || ident.length === 0 || !screens || screens.length === 0) return null + for (let i = 0; i < screens.length; ++i) { + const s = screens[i] + if (screenKey(s) === ident || s.name === ident) { + return s + } + } + return null + } + + function primaryScreen() { + const screens = Qt.application.screens + if (screens && screens.length > 0) return screens[0] + return window.screen ? window.screen : null + } + + function screenOriginX(s) { + return (s && s.virtualX !== undefined) ? s.virtualX : 0 + } + + function screenOriginY(s) { + return (s && s.virtualY !== undefined) ? s.virtualY : 0 + } + + // A screen's own size. Its desktopAvailable* properties cover the whole + // virtual desktop, which spans every monitor. + function screenAvailWidth(s) { + if (s && s.width > 0) return s.width + return Screen.width > 0 ? Screen.width : 1920 + } + + function screenAvailHeight(s) { + if (s && s.height > 0) return s.height + return Screen.height > 0 ? Screen.height : 1080 + } + + function currentScreen() { + const screens = Qt.application.screens + if (screens && screens.length > 0) { + const cx = window.x + Math.round(window.width / 2) + const cy = window.y + Math.round(window.height / 2) + for (let i = 0; i < screens.length; ++i) { + const s = screens[i] + const sx = screenOriginX(s) + const sy = screenOriginY(s) + const sw = screenAvailWidth(s) + const sh = screenAvailHeight(s) + if (cx >= sx && cx < sx + sw && cy >= sy && cy < sy + sh) { + return s + } + } + } + return window.screen ? window.screen : primaryScreen() + } + + function updateNormalPositionFromWindow() { + const s = currentScreen() + const key = screenKey(s) + if (key.length > 0) { + normalScreen = key + } + normalX = window.x - screenOriginX(s) + normalY = window.y - screenOriginY(s) + hasNormalPosition = true + } + + Timer { + id: restoreCompleteTimer + interval: 180 + onTriggered: { + if (!Preferences.windowMaximized && window.visibility === Window.Windowed) { + const s = findScreen(window.normalScreen) || primaryScreen() + const targetGlobalX = screenOriginX(s) + window.normalX + const targetGlobalY = screenOriginY(s) + window.normalY + if (window.hasNormalPosition) { + if (window.x !== targetGlobalX) window.x = targetGlobalX + if (window.y !== targetGlobalY) window.y = targetGlobalY + } else if (window.visible) { + window.updateNormalPositionFromWindow() + } + } + window.restoringGeometry = false + } + } + + Timer { + id: geometrySaveTimer + interval: 250 + onTriggered: window.saveWindowState() + } + + // Note: On Wayland compositors, client-side global window coordinates (x, y) read as 0 + // and setting them is a no-op; only window dimensions, target screen, and maximized state take effect. + function restoreAndBringToFront() { + restoringGeometry = true + const savedScreen = findScreen(Preferences.windowScreen) + const targetScreen = savedScreen ? savedScreen : primaryScreen() + const sx = screenOriginX(targetScreen) + const sy = screenOriginY(targetScreen) + const availW = screenAvailWidth(targetScreen) + const availH = screenAvailHeight(targetScreen) + + const desiredW = Math.max(minimumWidth, Preferences.windowWidth > 0 ? Preferences.windowWidth : 1120) + const desiredH = Math.max(minimumHeight, Preferences.windowHeight > 0 ? Preferences.windowHeight : 740) + const minW = Math.min(minimumWidth, availW) + const minH = Math.min(minimumHeight, availH) + const fittedW = Math.max(minW, Math.min(desiredW, availW)) + const fittedH = Math.max(minH, Math.min(desiredH, availH)) + + normalWidth = fittedW + normalHeight = fittedH + width = fittedW + height = fittedH + + const targetKey = screenKey(targetScreen) + if (targetKey.length > 0) { + normalScreen = targetKey + } + + if (savedScreen && Preferences.hasWindowPosition) { + const maxRelX = Math.max(0, availW - fittedW) + const maxRelY = Math.max(0, availH - fittedH) + normalX = Math.max(0, Math.min(Preferences.windowX, maxRelX)) + normalY = Math.max(0, Math.min(Preferences.windowY, maxRelY)) + hasNormalPosition = true + } else { + normalX = Math.max(0, Math.round((availW - fittedW) / 2)) + normalY = Math.max(0, Math.round((availH - fittedH) / 2)) + hasNormalPosition = true + } + + if (targetScreen && window.screen !== targetScreen) { + window.screen = targetScreen + } + x = sx + normalX + y = sy + normalY + + if (Preferences.windowMaximized) { + bringToFront() + showMaximized() + } else { + bringToFront() + x = sx + normalX + y = sy + normalY + } + restoreCompleteTimer.restart() + } + + function saveWindowState() { + geometrySaveTimer.stop() + if (window.visible && !restoringGeometry) { + const s = currentScreen() + const key = screenKey(s) + if (key.length > 0) { + normalScreen = key + } + if (window.visibility === Window.Windowed + && window.width >= window.minimumWidth && window.height >= window.minimumHeight) { + normalX = window.x - screenOriginX(s) + normalY = window.y - screenOriginY(s) + hasNormalPosition = true + normalWidth = Math.max(window.minimumWidth, window.width) + normalHeight = Math.max(window.minimumHeight, window.height) + } + } + Preferences.saveWindowGeometry( + normalScreen, + hasNormalPosition, + normalX, + normalY, + normalWidth, + normalHeight, + window.visibility === Window.Maximized + ) + } + property bool resizing: resizeSettleTimer.running Timer { id: resizeSettleTimer interval: 140 } - onWidthChanged: if (visible) resizeSettleTimer.restart() - onHeightChanged: if (visible) resizeSettleTimer.restart() + onXChanged: { + if (visible && !restoringGeometry && window.visibility === Window.Windowed) { + updateNormalPositionFromWindow() + geometrySaveTimer.restart() + } + } + onYChanged: { + if (visible && !restoringGeometry && window.visibility === Window.Windowed) { + updateNormalPositionFromWindow() + geometrySaveTimer.restart() + } + } + onWidthChanged: { + if (visible) { + resizeSettleTimer.restart() + if (!restoringGeometry && window.visibility === Window.Windowed) { + normalWidth = Math.max(window.minimumWidth, window.width) + geometrySaveTimer.restart() + } + } + } + onHeightChanged: { + if (visible) { + resizeSettleTimer.restart() + if (!restoringGeometry && window.visibility === Window.Windowed) { + normalHeight = Math.max(window.minimumHeight, window.height) + geometrySaveTimer.restart() + } + } + } + onWindowVisibilityChanged: { + if (visible && !restoringGeometry && (window.visibility === Window.Windowed || window.visibility === Window.Maximized)) { + geometrySaveTimer.restart() + } + } // Mica/Acrylic is applied by Windows a moment after it's requested. Showing // through before it's there reveals the bare desktop (a flash), so the diff --git a/desktop/app/src/bridge/prefs.rs b/desktop/app/src/bridge/prefs.rs index 7c371d07..c0f4b50c 100644 --- a/desktop/app/src/bridge/prefs.rs +++ b/desktop/app/src/bridge/prefs.rs @@ -135,6 +135,20 @@ pub mod qobject { #[qproperty(QString, folder_sync_rules_json)] /// JSON array of configured `AutomationRule` items. #[qproperty(QString, automation_rules_json)] + /// Identifier or name of the monitor where the main window was last placed. + #[qproperty(QString, window_screen)] + /// Whether a saved main window `(x, y)` position exists. + #[qproperty(bool, has_window_position)] + /// Saved main window X offset relative to the target screen in logical pixels. + #[qproperty(i32, window_x)] + /// Saved main window Y offset relative to the target screen in logical pixels. + #[qproperty(i32, window_y)] + /// Saved main window normal width in logical pixels. + #[qproperty(i32, window_width)] + /// Saved main window normal height in logical pixels. + #[qproperty(i32, window_height)] + /// Whether the main window was maximized when last open. + #[qproperty(bool, window_maximized)] type Preferences = super::PreferencesRust; /// Resets the setup walkthrough to step 0 and opens it again. @@ -144,6 +158,18 @@ pub mod qobject { /// `{ code, name }`, each named in its own language. #[qinvokable] fn languages(self: &Preferences) -> QString; + /// Saves the main window's monitor name/identifier, screen-relative position, dimensions, and maximized state. + #[qinvokable] + fn save_window_geometry( + self: Pin<&mut Preferences>, + screen: &QString, + has_position: bool, + x: i32, + y: i32, + width: i32, + height: i32, + maximized: bool, + ); /// OBS Studio's WebSocket port, for `obs:` Deck commands. #[qinvokable] fn obs_port(self: &Preferences) -> i32; @@ -306,6 +332,13 @@ pub struct PreferencesRust { hive_invite_dismissed: bool, folder_sync_rules_json: QString, automation_rules_json: QString, + window_screen: QString, + has_window_position: bool, + window_x: i32, + window_y: i32, + window_width: i32, + window_height: i32, + window_maximized: bool, /// What the user chose (`start_with_windows` shows the default until then). start_choice: Option, /// Custom recordings folder if chosen (`None` means default). @@ -391,6 +424,13 @@ impl cxx_qt::Initialize for qobject::Preferences { let automation_rules_json = serde_json::to_string(&settings.automation_rules).unwrap_or_else(|_| "[]".into()); self.as_mut().set_automation_rules_json(QString::from(&automation_rules_json)); + self.as_mut().set_window_screen(QString::from(settings.window_screen.as_deref().unwrap_or(""))); + self.as_mut().set_has_window_position(settings.window_x.is_some() && settings.window_y.is_some()); + self.as_mut().set_window_x(settings.window_x.unwrap_or(0)); + self.as_mut().set_window_y(settings.window_y.unwrap_or(0)); + self.as_mut().set_window_width(settings.window_width as i32); + self.as_mut().set_window_height(settings.window_height as i32); + self.as_mut().set_window_maximized(settings.window_maximized); crate::win::tray::update_hotkeys( &settings.continuity_photo_hotkey, &settings.continuity_scan_hotkey, @@ -701,6 +741,52 @@ impl qobject::Preferences { QString::from(&crate::i18n::languages_json()) } + pub fn save_window_geometry( + mut self: Pin<&mut Self>, + screen: &QString, + has_position: bool, + x: i32, + y: i32, + width: i32, + height: i32, + maximized: bool, + ) { + let clamped_w = (width.max(crate::settings::MIN_WINDOW_WIDTH as i32) as u32) + .min(crate::settings::MAX_WINDOW_WIDTH); + let clamped_h = (height.max(crate::settings::MIN_WINDOW_HEIGHT as i32) as u32) + .min(crate::settings::MAX_WINDOW_HEIGHT); + let screen_str = String::from(screen).trim().to_owned(); + let opt_screen = if screen_str.is_empty() { None } else { Some(screen_str.clone()) }; + let (opt_x, opt_y) = if has_position { (Some(x), Some(y)) } else { (None, None) }; + crate::settings::set_window_geometry(opt_screen, opt_x, opt_y, clamped_w, clamped_h, maximized); + let qscreen = QString::from(&screen_str); + if *self.window_screen() != qscreen { + self.as_mut().set_window_screen(qscreen); + } + if *self.has_window_position() != has_position { + self.as_mut().set_has_window_position(has_position); + } + if has_position { + if *self.window_x() != x { + self.as_mut().set_window_x(x); + } + if *self.window_y() != y { + self.as_mut().set_window_y(y); + } + } + if *self.window_width() != clamped_w as i32 { + self.as_mut().set_window_width(clamped_w as i32); + } + if *self.window_height() != clamped_h as i32 { + self.as_mut().set_window_height(clamped_h as i32); + } + if *self.window_maximized() != maximized { + self.as_mut().set_window_maximized(maximized); + } + let settings = self.current_settings(); + let _ = settings.save(&core_host::host().data_dir); + } + pub fn obs_port(&self) -> i32 { i32::from(crate::settings::obs_settings().0) } @@ -873,6 +959,7 @@ impl qobject::Preferences { fn current_settings(&self) -> Settings { let p = self.rust(); + let (win_screen, win_x, win_y, win_w, win_h, win_max) = crate::settings::window_geometry(); Settings { theme: match String::from(&p.theme).as_str() { "hive" => Theme::Hive, @@ -941,6 +1028,12 @@ impl qobject::Preferences { hive_invite_dismissed: p.hive_invite_dismissed, obs_port: crate::settings::obs_settings().0, obs_password: crate::settings::obs_settings().1, + window_screen: win_screen, + window_x: win_x, + window_y: win_y, + window_width: win_w, + window_height: win_h, + window_maximized: win_max, } } diff --git a/desktop/app/src/settings.rs b/desktop/app/src/settings.rs index 1999b23a..8a00937c 100644 --- a/desktop/app/src/settings.rs +++ b/desktop/app/src/settings.rs @@ -93,11 +93,27 @@ impl RecordingFormat { } pub const DEFAULT_LOCAL_API_PORT: u16 = 53320; +pub const DEFAULT_WINDOW_WIDTH: u32 = 1120; +pub const DEFAULT_WINDOW_HEIGHT: u32 = 740; +/// Broad sanity bounds for persisted window dimensions; the UI minimum size lives in +/// `Theme.layout.minWindow` (`Tokens.qml`) and adapts to the target screen's viewport. +pub const MIN_WINDOW_WIDTH: u32 = 320; +pub const MIN_WINDOW_HEIGHT: u32 = 240; +pub const MAX_WINDOW_WIDTH: u32 = 7680; +pub const MAX_WINDOW_HEIGHT: u32 = 4320; fn default_local_api_port() -> u16 { DEFAULT_LOCAL_API_PORT } +fn default_window_width() -> u32 { + DEFAULT_WINDOW_WIDTH +} + +fn default_window_height() -> u32 { + DEFAULT_WINDOW_HEIGHT +} + fn default_true() -> bool { true } @@ -123,6 +139,9 @@ static FOLDER_SYNC_RULES: std::sync::OnceLock>> = std::sync::OnceLock::new(); +type WindowGeometryState = (Option, Option, Option, u32, u32, bool); +static WINDOW_GEOMETRY: std::sync::Mutex = + std::sync::Mutex::new((None, None, None, DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT, false)); pub fn clipboard_auto_copy_received_files() -> bool { CLIPBOARD_AUTO_COPY_FILES.load(std::sync::atomic::Ordering::Relaxed) @@ -253,6 +272,30 @@ pub fn set_automation_rules(rules: Vec) { .unwrap_or_else(|e| e.into_inner()) = rules; } +pub fn window_geometry() -> (Option, Option, Option, u32, u32, bool) { + WINDOW_GEOMETRY.lock().unwrap_or_else(|e| e.into_inner()).clone() +} + +pub fn set_window_geometry( + screen: Option, + x: Option, + y: Option, + width: u32, + height: u32, + maximized: bool, +) { + let screen = screen.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + let (x, y) = if x.is_some() && y.is_some() { (x, y) } else { (None, None) }; + *WINDOW_GEOMETRY.lock().unwrap_or_else(|e| e.into_inner()) = ( + screen, + x, + y, + width.clamp(MIN_WINDOW_WIDTH, MAX_WINDOW_WIDTH), + height.clamp(MIN_WINDOW_HEIGHT, MAX_WINDOW_HEIGHT), + maximized, + ); +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Settings { @@ -421,6 +464,24 @@ pub struct Settings { pub obs_port: u16, /// OBS's WebSocket password, encrypted with Windows DPAPI (base64). Never stored in clear. pub obs_password: Option, + /// Target monitor/screen name or identifier where the main window was last placed. + #[serde(default, skip_serializing_if = "Option::is_none", alias = "window_screen")] + pub window_screen: Option, + /// Last saved main window X offset relative to `window_screen` in logical pixels (`None` = centered default). + #[serde(default, skip_serializing_if = "Option::is_none", alias = "window_x")] + pub window_x: Option, + /// Last saved main window Y offset relative to `window_screen` in logical pixels (`None` = centered default). + #[serde(default, skip_serializing_if = "Option::is_none", alias = "window_y")] + pub window_y: Option, + /// Last saved main window normal width in logical pixels. + #[serde(default = "default_window_width", alias = "window_width")] + pub window_width: u32, + /// Last saved main window normal height in logical pixels. + #[serde(default = "default_window_height", alias = "window_height")] + pub window_height: u32, + /// Whether the main window was maximized when last open. + #[serde(default, alias = "window_maximized")] + pub window_maximized: bool, } impl Default for Settings { @@ -481,6 +542,12 @@ impl Default for Settings { hive_invite_dismissed: false, obs_port: crate::obs::DEFAULT_PORT, obs_password: None, + window_screen: None, + window_x: None, + window_y: None, + window_width: DEFAULT_WINDOW_WIDTH, + window_height: DEFAULT_WINDOW_HEIGHT, + window_maximized: false, } } } @@ -493,6 +560,13 @@ impl Settings { pub fn sanitized(mut self) -> Self { self.webcam_camera = normalize_webcam_camera(&self.webcam_camera); self.walkthrough_step = self.walkthrough_step.min(6); + self.window_screen = self.window_screen.map(|s| s.trim().to_owned()).filter(|s| !s.is_empty()); + self.window_width = self.window_width.clamp(MIN_WINDOW_WIDTH, MAX_WINDOW_WIDTH); + self.window_height = self.window_height.clamp(MIN_WINDOW_HEIGHT, MAX_WINDOW_HEIGHT); + if self.window_x.is_none() || self.window_y.is_none() { + self.window_x = None; + self.window_y = None; + } self } @@ -523,6 +597,14 @@ impl Settings { set_share_pc_storage(self.share_pc_storage); set_forward_pc_notifications_to_phone(self.forward_pc_notifications_to_phone); set_away_mode(self.away_mode); + set_window_geometry( + self.window_screen.clone(), + self.window_x, + self.window_y, + self.window_width, + self.window_height, + self.window_maximized, + ); } /// Loads the settings, falling back to defaults if the file is missing. @@ -634,6 +716,12 @@ mod tests { hive_invite_dismissed: true, obs_port: crate::obs::DEFAULT_PORT, obs_password: None, + window_screen: Some("DP-1".into()), + window_x: Some(180), + window_y: Some(120), + window_width: 1280, + window_height: 820, + window_maximized: true, }; settings.save(dir.path()).unwrap(); assert_eq!(Settings::load(dir.path()), settings); @@ -906,4 +994,57 @@ mod tests { assert!(upgraded.reconcile_walkthrough_for_paired_devices(2)); assert!(upgraded.walkthrough_completed); } + + #[test] + fn window_geometry_sanitization_and_persistence() { + let dir = tempfile::tempdir().unwrap(); + + // 1. Out-of-bounds dimensions are clamped to [MIN_WINDOW_*, MAX_WINDOW_*], + // and screen identifiers are trimmed. + fs::write( + dir.path().join(FILE_NAME), + r#"{ "windowScreen": " HDMI-A-1 ", "windowWidth": 100, "windowHeight": 80, "windowX": 40, "windowY": 50, "windowMaximized": true }"#, + ) + .unwrap(); + let clamped_small = Settings::load(dir.path()); + assert_eq!(clamped_small.window_screen.as_deref(), Some("HDMI-A-1")); + assert_eq!(clamped_small.window_width, MIN_WINDOW_WIDTH); + assert_eq!(clamped_small.window_height, MIN_WINDOW_HEIGHT); + assert_eq!(clamped_small.window_x, Some(40)); + assert_eq!(clamped_small.window_y, Some(50)); + assert!(clamped_small.window_maximized); + + // 2. Tokens.qml minimum size (880x600) and viewport-shrunk sizes (e.g. 720x500) are preserved intact. + fs::write( + dir.path().join(FILE_NAME), + r#"{ "windowScreen": "eDP-1", "windowWidth": 880, "windowHeight": 600, "windowX": 0, "windowY": 0 }"#, + ) + .unwrap(); + let tokens_min = Settings::load(dir.path()); + assert_eq!(tokens_min.window_screen.as_deref(), Some("eDP-1")); + assert_eq!(tokens_min.window_width, 880); + assert_eq!(tokens_min.window_height, 600); + + fs::write( + dir.path().join(FILE_NAME), + r#"{ "window_screen": " ", "window_width": 99999, "window_height": 99999 }"#, + ) + .unwrap(); + let clamped_large = Settings::load(dir.path()); + assert_eq!(clamped_large.window_screen, None); + assert_eq!(clamped_large.window_width, MAX_WINDOW_WIDTH); + assert_eq!(clamped_large.window_height, MAX_WINDOW_HEIGHT); + + // 3. Partial coordinates (only X or only Y) are cleared so both are None. + let partial = Settings { + window_screen: Some("DP-2".into()), + window_x: Some(120), + window_y: None, + ..Default::default() + } + .sanitized(); + assert_eq!(partial.window_screen.as_deref(), Some("DP-2")); + assert_eq!(partial.window_x, None); + assert_eq!(partial.window_y, None); + } }