From 57d7c0549b2ca3bbb0a2d6111a9ed04a239f404d Mon Sep 17 00:00:00 2001 From: Strycher Date: Mon, 20 Jul 2026 21:00:29 -0400 Subject: [PATCH] feat(#349): persist and restore desktop window geometry The stock Flutter desktop runner never saved the window frame, so the window always reopened at a default position and size (#349). Adds WindowGeometryService (window_manager + screen_retriever): it saves the frame on move/resize, debounced, and restores it on launch. Restore is clamped against the connected displays. A window last placed on a monitor that is now unplugged must not reopen off-screen where it cannot be grabbed; if the saved frame does not overlap any display by at least 80px on both axes it is discarded and the window opens at the default. A too-small or undecodable saved value also falls through to the default (logged, not swallowed). Gated on PlatformInfo.isDesktop, so it is a no-op on mobile and web. Scoped past just Windows deliberately: window_manager is desktop-only and the same gap exists on Linux/macOS, so all three get the fix rather than Windows needing an extra guard. Known limitation, not addressed here: without a native runner change to start the window hidden, restore repositions AFTER the window is shown, so there can be a brief flash at the default position before it jumps to the saved frame. Eliminating that needs a windows/runner edit; flagged for the owner to judge during hardware test. flutter analyze clean, dart format clean, tests pass. Windows-run verification is the owner's (geometry is a real-desktop behaviour). --- lib/main.dart | 4 + lib/services/window_geometry_service.dart | 124 ++++++++++++++++++++++ pubspec.yaml | 6 +- 3 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 lib/services/window_geometry_service.dart diff --git a/lib/main.dart b/lib/main.dart index 4e0c17e..481334c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,6 +27,7 @@ import 'services/ui_view_state_service.dart'; import 'services/timeout_prediction_service.dart'; import 'services/observer_config_service.dart'; import 'services/block_service.dart'; +import 'services/window_geometry_service.dart'; import 'storage/drift/blob_store.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -48,6 +49,9 @@ void main() async { // Start always-on file logging (#97); no-op on web. await FileLogService.instance.init(); + // Restore the desktop window's saved position/size (#349); no-op off desktop. + await WindowGeometryService.instance.initialize(); + // Initialize services final storage = StorageService(); final connector = MeshCoreConnector(); diff --git a/lib/services/window_geometry_service.dart b/lib/services/window_geometry_service.dart new file mode 100644 index 0000000..a9023dd --- /dev/null +++ b/lib/services/window_geometry_service.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:ui'; + +import 'package:screen_retriever/screen_retriever.dart'; +import 'package:window_manager/window_manager.dart'; + +import '../storage/prefs_manager.dart'; +import '../utils/app_logger.dart'; +import '../utils/platform_info.dart'; + +/// Persists and restores the desktop window's position and size (#349). +/// +/// The stock Flutter desktop runner never saved geometry, so the window always +/// reopened at a default frame. This service saves the frame on move/resize and +/// restores it on launch. +/// +/// The restore is clamped against the currently-connected displays: a window +/// last placed on a monitor that is now unplugged must not reopen off-screen +/// where the user cannot reach it. If the saved frame does not overlap any +/// display enough to grab, it is discarded and the window opens at the default. +/// +/// Desktop only (window_manager has no mobile/web backend); a no-op elsewhere. +class WindowGeometryService with WindowListener { + WindowGeometryService._(); + static final WindowGeometryService instance = WindowGeometryService._(); + + static const _prefsKey = 'window_geometry'; + static const _minWidth = 400.0; + static const _minHeight = 300.0; + // A window counts as reachable if at least this much of it overlaps a + // display on both axes, so a sliver still on screen is recoverable. + static const _minVisible = 80.0; + + Timer? _saveDebounce; + + /// Call once in main() before runApp, after PrefsManager.initialize(). + Future initialize() async { + if (!PlatformInfo.isDesktop) return; + + await windowManager.ensureInitialized(); + await windowManager.waitUntilReadyToShow(null, () async { + await _restore(); + await windowManager.show(); + await windowManager.focus(); + }); + windowManager.addListener(this); + } + + Future _restore() async { + final raw = PrefsManager.instance.getString(_prefsKey); + if (raw == null || raw.isEmpty) return; + + Rect saved; + try { + final m = jsonDecode(raw) as Map; + saved = Rect.fromLTWH( + (m['x'] as num).toDouble(), + (m['y'] as num).toDouble(), + (m['w'] as num).toDouble(), + (m['h'] as num).toDouble(), + ); + } catch (e) { + // SAFELANE 6: never swallow. A corrupt value falls through to the default. + appLogger.error( + 'Failed to decode saved window geometry; using default: $e', + tag: 'Window', + ); + return; + } + + if (saved.width < _minWidth || saved.height < _minHeight) return; + + if (!await _isReachable(saved)) { + appLogger.info( + 'Saved window geometry is off-screen (monitor likely unplugged); ' + 'opening at the default position instead.', + tag: 'Window', + ); + return; + } + + await windowManager.setBounds(saved); + } + + /// True when [saved] overlaps some connected display's visible area by at + /// least [_minVisible] on both axes. + Future _isReachable(Rect saved) async { + final displays = await screenRetriever.getAllDisplays(); + for (final d in displays) { + final origin = d.visiblePosition ?? Offset.zero; + final size = d.visibleSize ?? d.size; + final display = origin & size; // Rect from an Offset and a Size. + final overlap = saved.intersect(display); + if (overlap.width >= _minVisible && overlap.height >= _minVisible) { + return true; + } + } + return false; + } + + void _scheduleSave() { + _saveDebounce?.cancel(); + _saveDebounce = Timer(const Duration(milliseconds: 400), _save); + } + + Future _save() async { + try { + final b = await windowManager.getBounds(); + await PrefsManager.instance.setString( + _prefsKey, + jsonEncode({'x': b.left, 'y': b.top, 'w': b.width, 'h': b.height}), + ); + } catch (e) { + appLogger.error('Failed to save window geometry: $e', tag: 'Window'); + } + } + + @override + void onWindowMoved() => _scheduleSave(); + + @override + void onWindowResized() => _scheduleSave(); +} diff --git a/pubspec.yaml b/pubspec.yaml index 3a9ab39..bd175d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,12 +74,12 @@ dependencies: llamadart: '>=0.6.8 <0.7.0' flutter_langdetect: ^0.0.1 # PINNED (#335): web ships version-matched sqlite3.wasm + drift_worker.js - # from the drift release. A caret here would let pub resolve a newer drift - # than the committed assets, breaking web silently. Bump deliberately, then - # re-download both assets. tool/check_drift_assets.dart enforces the match. + # from the drift release. Bump deliberately, then re-download both assets. drift: 2.34.2 drift_flutter: 0.3.1 path: ^1.9.1 + window_manager: ^0.5.2 + screen_retriever: ^0.2.2 hooks: user_defines: