Skip to content
Merged
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
47 changes: 47 additions & 0 deletions app/lib/src/state/close_to_tray.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,53 @@ final closeToTrayProvider = NotifierProvider<CloseToTray, bool?>(
CloseToTray.new,
);

/// Windows: minimize hides the window only when a tray is available.
class MinimizeToTray extends Notifier<bool> {
static const prefsKey = 'minimize_to_tray';

@override
bool build() {
_load();
return false;
}

Future<void> _load() async {
try {
final p = await SharedPreferences.getInstance();
final v = p.getBool(prefsKey);
if (v != null) state = v;
} catch (_) {
// Unreadable: minimize normally.
}
}

Future<void> 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, bool>(
MinimizeToTray.new,
);

bool effectiveMinimizeToTray({
required bool setting,
required bool trayAvailable,
}) => setting && trayAvailable;

final effectiveMinimizeToTrayProvider = Provider<bool>((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.
Expand Down
4 changes: 4 additions & 0 deletions app/lib/src/state/desktop_window.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ class DesktopWindow {
static Future<void> setInterceptClose(bool intercept) =>
_call<void>('setInterceptClose', intercept);

/// Windows: hide on minimize while a tray icon can bring the window back.
static Future<void> setMinimizeToTray(bool enabled) =>
_call<void>('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<bool?> nativeAutostartEnabled() =>
Expand Down
8 changes: 8 additions & 0 deletions app/lib/src/ui/app.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -76,6 +77,13 @@ class _AircloneAppState extends ConsumerState<AircloneApp> {
/// desktop without an indicator host; X then quits as it always has.
void _startTray() {
if (!HostPlatform.isDesktop) return;
if (HostPlatform.isWindows) {
ref.listenManual<bool>(
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
Expand Down
20 changes: 20 additions & 0 deletions app/lib/src/ui/tray_settings_section.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion app/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions app/test/tray_settings_widget_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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()),
Expand Down
70 changes: 70 additions & 0 deletions app/test/tray_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>.delayed(Duration.zero);
expect(c.read(minimizeToTrayProvider), isFalse);

SharedPreferences.setMockInitialValues({MinimizeToTray.prefsKey: true});
final saved = ProviderContainer();
addTearDown(saved.dispose);
saved.read(minimizeToTrayProvider);
await Future<void>.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<void>.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 = <bool>[];
c.listen<bool>(
effectiveMinimizeToTrayProvider,
(_, value) => values.add(value),
fireImmediately: true,
);
await Future<void>.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');
Expand Down
14 changes: 14 additions & 0 deletions app/windows/runner/flutter_window.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool>(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") {
Expand Down Expand Up @@ -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<LRESULT> result =
Expand Down
1 change: 1 addition & 0 deletions app/windows/runner/flutter_window.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ class FlutterWindow : public Win32Window {
window_channel_;

bool start_hidden_ = false;
bool minimize_to_tray_ = false;
};

#endif // RUNNER_FLUTTER_WINDOW_H_
15 changes: 15 additions & 0 deletions dev/logs/agent-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions dev/releases/v0.31.0.md
Original file line number Diff line number Diff line change
@@ -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).
5 changes: 5 additions & 0 deletions docs/guide/tray.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading