diff --git a/app/lib/src/state/close_to_tray.dart b/app/lib/src/state/close_to_tray.dart index 8271965..4c1af56 100644 --- a/app/lib/src/state/close_to_tray.dart +++ b/app/lib/src/state/close_to_tray.dart @@ -53,6 +53,53 @@ final closeToTrayProvider = NotifierProvider( CloseToTray.new, ); +/// Windows: minimize hides the window only when a tray is available. +class MinimizeToTray extends Notifier { + static const prefsKey = 'minimize_to_tray'; + + @override + bool build() { + _load(); + return false; + } + + Future _load() async { + try { + final p = await SharedPreferences.getInstance(); + final v = p.getBool(prefsKey); + if (v != null) state = v; + } catch (_) { + // Unreadable: minimize normally. + } + } + + Future set(bool value) async { + state = value; + try { + final p = await SharedPreferences.getInstance(); + await p.setBool(prefsKey, value); + } catch (_) { + // Best effort: the in-memory choice still holds for this session. + } + } +} + +final minimizeToTrayProvider = NotifierProvider( + MinimizeToTray.new, +); + +bool effectiveMinimizeToTray({ + required bool setting, + required bool trayAvailable, +}) => setting && trayAvailable; + +final effectiveMinimizeToTrayProvider = Provider((ref) { + return effectiveMinimizeToTray( + setting: ref.watch(minimizeToTrayProvider), + trayAvailable: ref.watch(trayAvailableProvider), + ); +}); + /// Whether a tray icon is up right now. Set by the tray controller; false on /// mobile, web, and on a Linux desktop with no indicator host, which is what /// makes X quit there exactly as it always has. diff --git a/app/lib/src/state/desktop_window.dart b/app/lib/src/state/desktop_window.dart index f67b1c0..661f561 100644 --- a/app/lib/src/state/desktop_window.dart +++ b/app/lib/src/state/desktop_window.dart @@ -48,6 +48,10 @@ class DesktopWindow { static Future setInterceptClose(bool intercept) => _call('setInterceptClose', intercept); + /// Windows: hide on minimize while a tray icon can bring the window back. + static Future setMinimizeToTray(bool enabled) => + _call('setMinimizeToTray', enabled); + /// Windows (Run key) and macOS 13+ (SMAppService): whether Airclone starts /// at sign-in. Null when the runner cannot do it here. static Future nativeAutostartEnabled() => diff --git a/app/lib/src/ui/app.dart b/app/lib/src/ui/app.dart index f2a717d..7579ee6 100644 --- a/app/lib/src/ui/app.dart +++ b/app/lib/src/ui/app.dart @@ -1,5 +1,6 @@ // AppExitResponse (the onExitRequested return) lives in dart:ui — services.dart // only ever refers to it prefixed, so it is not re-exported by material. +import 'dart:async'; import 'dart:ui' show AppExitResponse, AppExitType; import 'package:flutter/material.dart'; @@ -76,6 +77,13 @@ class _AircloneAppState extends ConsumerState { /// desktop without an indicator host; X then quits as it always has. void _startTray() { if (!HostPlatform.isDesktop) return; + if (HostPlatform.isWindows) { + ref.listenManual( + effectiveMinimizeToTrayProvider, + (_, enabled) => unawaited(DesktopWindow.setMinimizeToTray(enabled)), + fireImmediately: true, + ); + } final tray = ref.read(trayControllerProvider)..onQuit = _quitApp; // macOS: Dart cannot tell the close button from Cmd-Q (both arrive as // applicationShouldTerminate), so with a tray up the runner intercepts diff --git a/app/lib/src/ui/tray_settings_section.dart b/app/lib/src/ui/tray_settings_section.dart index 97e5eaa..604041d 100644 --- a/app/lib/src/ui/tray_settings_section.dart +++ b/app/lib/src/ui/tray_settings_section.dart @@ -14,6 +14,7 @@ class TraySettingsSection extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final closeToTray = ref.watch(closeToTrayProvider) ?? false; + final minimizeToTray = ref.watch(minimizeToTrayProvider); final trayUp = ref.watch(trayAvailableProvider); final autostart = ref.watch(autostartProvider); return Column( @@ -32,6 +33,25 @@ class TraySettingsSection extends ConsumerWidget { ? (v) => ref.read(closeToTrayProvider.notifier).set(v) : null, ), + if (HostPlatform.isWindows) ...[ + const SizedBox(height: Space.x4), + _SwitchRow( + key: const ValueKey('tray.minimizeToTray'), + title: 'Minimizing the window hides it to the tray', + help: trayUp + ? 'The minimize button hides the window. Click the tray icon ' + 'to bring it back.' + : 'The tray icon is not available, so minimizing the window ' + 'keeps it in the taskbar.', + value: effectiveMinimizeToTray( + setting: minimizeToTray, + trayAvailable: trayUp, + ), + onChanged: trayUp + ? (v) => ref.read(minimizeToTrayProvider.notifier).set(v) + : null, + ), + ], const SizedBox(height: Space.x4), if (autostart.support.supported) _SwitchRow( diff --git a/app/pubspec.yaml b/app/pubspec.yaml index d0cb38b..eaa741e 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.30.1+164 +version: 0.31.0+165 environment: sdk: ^3.12.0 diff --git a/app/test/tray_settings_widget_test.dart b/app/test/tray_settings_widget_test.dart index d4f9e20..36abb48 100644 --- a/app/test/tray_settings_widget_test.dart +++ b/app/test/tray_settings_widget_test.dart @@ -5,6 +5,7 @@ library; import 'package:airclone/src/state/autostart.dart'; import 'package:airclone/src/state/close_to_tray.dart'; +import 'package:airclone/src/state/host_platform.dart'; import 'package:airclone/src/ui/theme/app_theme.dart'; import 'package:airclone/src/ui/tray_settings_section.dart'; import 'package:flutter/material.dart'; @@ -98,6 +99,27 @@ void main() { expect(find.textContaining('closing the window quits'), findsOneWidget); }); + testWidgets('minimize-to-tray is Windows only and needs a tray', ( + tester, + ) async { + final c = await pump(tester, trayUp: true); + final row = find.byKey(const ValueKey('tray.minimizeToTray')); + if (!HostPlatform.isWindows) { + expect(row, findsNothing); + return; + } + expect(row, findsOneWidget); + expect(switchOf(tester, 'tray.minimizeToTray').value, isFalse); + await tester.tap(find.descendant(of: row, matching: find.byType(Switch))); + await tester.pumpAndSettle(); + expect(c.read(minimizeToTrayProvider), isTrue); + expect(switchOf(tester, 'tray.minimizeToTray').value, isTrue); + c.read(trayAvailableProvider.notifier).set(false); + await tester.pumpAndSettle(); + expect(switchOf(tester, 'tray.minimizeToTray').onChanged, isNull); + expect(switchOf(tester, 'tray.minimizeToTray').value, isFalse); + }); + testWidgets('sign-in launch: a switch when supported', (tester) async { final fake = FakeAutostart( const AutostartState(support: AutostartSupport.supported()), diff --git a/app/test/tray_test.dart b/app/test/tray_test.dart index 4abfb4e..3773a19 100644 --- a/app/test/tray_test.dart +++ b/app/test/tray_test.dart @@ -213,6 +213,76 @@ void main() { }); }); + group('MinimizeToTray', () { + test('defaults off and loads a saved choice', () async { + SharedPreferences.setMockInitialValues({}); + final c = ProviderContainer(); + addTearDown(c.dispose); + expect(c.read(minimizeToTrayProvider), isFalse); + await Future.delayed(Duration.zero); + expect(c.read(minimizeToTrayProvider), isFalse); + + SharedPreferences.setMockInitialValues({MinimizeToTray.prefsKey: true}); + final saved = ProviderContainer(); + addTearDown(saved.dispose); + saved.read(minimizeToTrayProvider); + await Future.delayed(Duration.zero); + expect(saved.read(minimizeToTrayProvider), isTrue); + }); + + test('persists both choices', () async { + SharedPreferences.setMockInitialValues({}); + final c = ProviderContainer(); + addTearDown(c.dispose); + c.read(minimizeToTrayProvider); + await Future.delayed(Duration.zero); + for (final value in [true, false]) { + await c.read(minimizeToTrayProvider.notifier).set(value); + expect(c.read(minimizeToTrayProvider), value); + final p = await SharedPreferences.getInstance(); + expect(p.getBool(MinimizeToTray.prefsKey), value); + } + }); + + test('only effective with the setting and a tray', () { + for (final setting in [false, true]) { + for (final trayAvailable in [false, true]) { + expect( + effectiveMinimizeToTray( + setting: setting, + trayAvailable: trayAvailable, + ), + setting && trayAvailable, + ); + } + } + }); + + test('effective provider follows setting and tray changes', () async { + SharedPreferences.setMockInitialValues({}); + final c = ProviderContainer(); + addTearDown(c.dispose); + final values = []; + c.listen( + effectiveMinimizeToTrayProvider, + (_, value) => values.add(value), + fireImmediately: true, + ); + await Future.delayed(Duration.zero); + await c.read(minimizeToTrayProvider.notifier).set(true); + expect(c.read(effectiveMinimizeToTrayProvider), isFalse); + c.read(trayAvailableProvider.notifier).set(true); + expect(c.read(effectiveMinimizeToTrayProvider), isTrue); + await c.read(minimizeToTrayProvider.notifier).set(false); + expect(c.read(effectiveMinimizeToTrayProvider), isFalse); + await c.read(minimizeToTrayProvider.notifier).set(true); + expect(c.read(effectiveMinimizeToTrayProvider), isTrue); + c.read(trayAvailableProvider.notifier).set(false); + expect(c.read(effectiveMinimizeToTrayProvider), isFalse); + expect(values, [false, true, false, true, false]); + }); + }); + group('bandwidth helpers', () { test('labels', () { expect(trayBandwidthLabel('off'), 'Unlimited'); diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp index 977c682..9853873 100644 --- a/app/windows/runner/flutter_window.cpp +++ b/app/windows/runner/flutter_window.cpp @@ -147,6 +147,14 @@ bool FlutterWindow::OnCreate() { } else if (method == "setInterceptClose") { // Nothing to do: WM_CLOSE already reaches Dart as an exit request. result->Success(); + } else if (method == "setMinimizeToTray") { + const auto* on = std::get_if(call.arguments()); + if (on == nullptr) { + result->Error("bad-args", "setMinimizeToTray takes a bool"); + return; + } + minimize_to_tray_ = *on; + result->Success(); } else if (method == "autostartEnabled") { result->Success(flutter::EncodableValue(AutostartEnabled())); } else if (method == "setAutostart") { @@ -198,6 +206,12 @@ LRESULT FlutterWindow::MessageHandler(HWND hwnd, UINT const message, WPARAM const wparam, LPARAM const lparam) noexcept { + const bool hide_on_minimize = + message == WM_SIZE && wparam == SIZE_MINIMIZED && minimize_to_tray_; + if (hide_on_minimize) { + ::ShowWindow(hwnd, SW_HIDE); + } + // Give Flutter, including plugins, an opportunity to handle window messages. if (flutter_controller_) { std::optional result = diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h index 97a497e..9590da8 100644 --- a/app/windows/runner/flutter_window.h +++ b/app/windows/runner/flutter_window.h @@ -52,6 +52,7 @@ class FlutterWindow : public Win32Window { window_channel_; bool start_hidden_ = false; + bool minimize_to_tray_ = false; }; #endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/dev/logs/agent-changelog.md b/dev/logs/agent-changelog.md index 7eafae4..f31b9fb 100644 --- a/dev/logs/agent-changelog.md +++ b/dev/logs/agent-changelog.md @@ -13,6 +13,21 @@ happened": nothing was logged between 2026-07-02 and 2026-07-15, or between 2026 it is: it used to say ABOVE, which pushed it further down the file with every entry until it sat hundreds of lines under the newest one and pointed writers at the wrong place. --> +## [2026-10-26] - Opt-in minimize to tray on Windows + +**Agent:** Codex (Sol), reviewed and applied by Claude Code (Claude Opus 5.5) +**Files Modified:** `app/lib/src/state/{close_to_tray,desktop_window}.dart`, +`app/lib/src/ui/{app,tray_settings_section}.dart`, `app/windows/runner/flutter_window.{cpp,h}`, +`app/test/{tray_test,tray_settings_widget_test}.dart`, `docs/guide/tray.md`, +`app/pubspec.yaml` (0.31.0+165), `dev/releases/v0.31.0.md`, this changelog. +**Database/API Changes:** New boolean SharedPreferences key `minimize_to_tray` (default false) +and Windows `airclone/window` method `setMinimizeToTray` with a validated bool argument. +**Summary:** Windows users can opt into hiding the window when minimized. The app pushes the +setting gated by tray availability at startup and on changes; the close setting stays independent. +Native minimize handling preserves base window sizing, and existing tray/second-launch restore +paths restore iconic windows. Added persistence, effective-state and widget coverage plus docs. +Requested by Jake after v0.30.1: "minimize to the tray as well ... on windows". + ## [2026-10-26] - v0.30.1: Flatpak tray library builds again (Linux files back) **Agent:** Claude Code (Claude Opus 5.5) diff --git a/dev/releases/v0.31.0.md b/dev/releases/v0.31.0.md new file mode 100644 index 0000000..4ed463f --- /dev/null +++ b/dev/releases/v0.31.0.md @@ -0,0 +1,21 @@ +# Airclone v0.31.0 — minimize to the tray on Windows + +## Minimizing can hide Airclone in the tray (#54) + +Settings → Tray → **Minimizing the window hides it to the tray**. It's off by +default and only on Windows. Turn it on and the minimize button (or Win+Down) +hides the window instead of leaving it on the taskbar. Transfers, mounted +drives, servers and scheduled tasks keep running. Click the tray icon, choose +**Open Airclone** from its menu, or start Airclone again to bring the window +back. + +It works on its own: you don't need **Closing the window keeps Airclone +running in the tray** or **Start Airclone in the tray when I sign in** turned +on. Without a tray icon the switch is greyed out and minimizing works as +before. + +## Notes + +- macOS, Linux, Android, iPhone, iPad and the Web UI are unchanged. + +Full details: [Keeping Airclone in the tray](../../docs/guide/tray.md). diff --git a/docs/guide/tray.md b/docs/guide/tray.md index a827fee..ddfbd2d 100644 --- a/docs/guide/tray.md +++ b/docs/guide/tray.md @@ -25,6 +25,11 @@ The switch is greyed out when there is no tray to hide in. Closing the window th so Airclone never ends up running with no window and no icon. See [Linux needs a tray host](#linux-needs-a-tray-host). +On **Windows only**, Settings → Tray → **Minimizing the window hides it to the tray** +lets the minimize button hide the window too. It is **off by default**, independent of the +close-to-tray setting, and disabled without a tray icon. Click the tray icon or choose +**Open Airclone** from its menu to restore the window; transfers, mounts and schedules keep running. + ## What keeps running in the tray Everything that runs while the window is open keeps running while it is hidden: