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).
feat/349-window-geometry
Strycher 20 hours ago
parent 8af0e0d5dc
commit 57d7c0549b

@ -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();

@ -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<void> 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<void> _restore() async {
final raw = PrefsManager.instance.getString(_prefsKey);
if (raw == null || raw.isEmpty) return;
Rect saved;
try {
final m = jsonDecode(raw) as Map<String, dynamic>;
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<bool> _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<void> _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();
}

@ -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:

Loading…
Cancel
Save

Powered by TurnKey Linux.