diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 63d6c46..89740e6 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -351,6 +351,12 @@ jobs: libgtk-3-dev liblzma-dev xvfb dbus-x11 \ python3-dbus python3-gi + - name: Run Linux Unit Tests + run: | + cd wakelock_plus + flutter pub get + flutter test test/wakelock_plus_linux_plugin_test.dart + - name: Run Integration Tests run: | flutter config --enable-linux-desktop @@ -358,22 +364,27 @@ jobs: flutter pub get # We use xvfb-run (display) and dbus-run-session (bus). - # Inside, we run a Python mock of 'org.freedesktop.ScreenSaver' so the plugin logic succeeds. + # Inside, we run a Python mock of 'org.freedesktop.portal.Desktop' so the plugin logic succeeds. xvfb-run dbus-run-session bash -c " python3 -c \" import dbus, dbus.service from dbus.mainloop.glib import DBusGMainLoop from gi.repository import GLib - class Mock(dbus.service.Object): - def __init__(self): - bus_name = dbus.service.BusName('org.freedesktop.ScreenSaver', bus=dbus.SessionBus()) - dbus.service.Object.__init__(self, bus_name, '/org/freedesktop/ScreenSaver') - @dbus.service.method('org.freedesktop.ScreenSaver', in_signature='ss', out_signature='u') - def Inhibit(self, a, r): return 1 - @dbus.service.method('org.freedesktop.ScreenSaver', in_signature='u', out_signature='') - def UnInhibit(self, c): pass + class RequestMock(dbus.service.Object): + def __init__(self, bus): + dbus.service.Object.__init__(self, bus, '/org/freedesktop/portal/desktop/request/1_1/test') + @dbus.service.method('org.freedesktop.portal.Request', in_signature='', out_signature='') + def Close(self): pass + class DesktopMock(dbus.service.Object): + def __init__(self, bus): + bus_name = dbus.service.BusName('org.freedesktop.portal.Desktop', bus=bus) + dbus.service.Object.__init__(self, bus_name, '/org/freedesktop/portal/desktop') + @dbus.service.method('org.freedesktop.portal.Inhibit', in_signature='sua{sv}', out_signature='o') + def Inhibit(self, window, flags, options): return dbus.ObjectPath('/org/freedesktop/portal/desktop/request/1_1/test') DBusGMainLoop(set_as_default=True) - Mock() + bus = dbus.SessionBus() + DesktopMock(bus) + RequestMock(bus) GLib.MainLoop().run()\" & sleep 5 && \ flutter drive \ diff --git a/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart b/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart index 187b39b..a36f21e 100644 --- a/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart +++ b/wakelock_plus/lib/src/wakelock_plus_linux_plugin.dart @@ -8,8 +8,8 @@ import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interfac /// The Linux implementation of the [WakelockPlusPlatformInterface]. /// /// This class implements the `wakelock_plus` plugin functionality for Linux -/// using the `org.freedesktop.ScreenSaver` D-Bus API -/// (see https://specifications.freedesktop.org/idle-inhibit-spec/latest/re01.html). +/// using the `org.freedesktop.portal.Inhibit` D-Bus API +/// (see https://flatpak.github.io/xdg-desktop-portal/docs/doc-org.freedesktop.portal.Inhibit). class WakelockPlusLinuxPlugin extends WakelockPlusPlatformInterface { /// Registers this class as the default instance of [WakelockPlatformInterface]. static void registerWith() { @@ -17,45 +17,75 @@ class WakelockPlusLinuxPlugin extends WakelockPlusPlatformInterface { } /// Constructs an instance of [WakelockPlusLinuxPlugin]. - WakelockPlusLinuxPlugin({@visibleForTesting DBusRemoteObject? object}) - : _object = object ?? _createRemoteObject(); - - final DBusRemoteObject _object; - int? _cookie; - - static DBusRemoteObject _createRemoteObject() { - return DBusRemoteObject( - DBusClient.session(), - name: 'org.freedesktop.ScreenSaver', - path: DBusObjectPath('/org/freedesktop/ScreenSaver'), + factory WakelockPlusLinuxPlugin({ + @visibleForTesting DBusClient? client, + @visibleForTesting DBusRemoteObject? object, + @visibleForTesting Future Function()? appNameGetter, + }) { + final dbusClient = client ?? DBusClient.session(); + final remoteObject = + object ?? + DBusRemoteObject( + dbusClient, + name: 'org.freedesktop.portal.Desktop', + path: DBusObjectPath('/org/freedesktop/portal/desktop'), + ); + return WakelockPlusLinuxPlugin._internal( + dbusClient, + remoteObject, + appNameGetter, ); } + WakelockPlusLinuxPlugin._internal( + this._client, + this._object, + this._appNameGetter, + ); + + final DBusClient _client; + final DBusRemoteObject _object; + final Future Function()? _appNameGetter; + DBusObjectPath? _requestHandle; + Future get _appName => + _appNameGetter?.call() ?? PackageInfo.fromPlatform().then((info) => info.appName); @override Future toggle({required bool enable}) async { if (enable) { - _cookie = await _object + final appName = await _appName; + _requestHandle = await _object .callMethod( - 'org.freedesktop.ScreenSaver', + 'org.freedesktop.portal.Inhibit', 'Inhibit', - [DBusString(await _appName), const DBusString('wakelock')], - replySignature: DBusSignature.uint32, + [ + const DBusString(''), + const DBusUint32(8), + DBusDict.stringVariant({ + 'reason': DBusString('$appName: wakelock active'), + }), + ], + replySignature: DBusSignature('o'), ) - .then((response) => response.returnValues.single.asUint32()); - } else if (_cookie != null) { - await _object.callMethod( - 'org.freedesktop.ScreenSaver', - 'UnInhibit', - [DBusUint32(_cookie!)], + .then((response) => response.returnValues.single.asObjectPath()); + } else if (_requestHandle != null) { + final requestObject = DBusRemoteObject( + _client, + name: 'org.freedesktop.portal.Desktop', + path: _requestHandle!, + ); + await requestObject.callMethod( + 'org.freedesktop.portal.Request', + 'Close', + [], replySignature: DBusSignature.empty, ); - _cookie = null; + _requestHandle = null; } } @override - Future get enabled async => _cookie != null; + Future get enabled async => _requestHandle != null; } diff --git a/wakelock_plus/pubspec.yaml b/wakelock_plus/pubspec.yaml index 88191aa..438b568 100644 --- a/wakelock_plus/pubspec.yaml +++ b/wakelock_plus/pubspec.yaml @@ -33,7 +33,8 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 pigeon: ^26.2.3 # dart run pigeon --input "pigeons/messages.dart" - + mocktail: ^1.0.4 + # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -99,4 +100,4 @@ flutter: # https://flutter.dev/custom-fonts/#from-packages assets: - - packages/wakelock_plus/assets/no_sleep.js + - packages/wakelock_plus/assets/no_sleep.js \ No newline at end of file diff --git a/wakelock_plus/test/wakelock_plus_linux_plugin_test.dart b/wakelock_plus/test/wakelock_plus_linux_plugin_test.dart new file mode 100644 index 0000000..8929257 --- /dev/null +++ b/wakelock_plus/test/wakelock_plus_linux_plugin_test.dart @@ -0,0 +1,267 @@ +import 'package:dbus/dbus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:wakelock_plus/src/wakelock_plus_linux_plugin.dart'; +import 'package:wakelock_plus_platform_interface/wakelock_plus_platform_interface.dart'; + +class MockDBusClient extends Mock implements DBusClient {} + +class MockDBusRemoteObject extends Mock implements DBusRemoteObject {} + +class FakeDBusSignature extends Fake implements DBusSignature {} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(FakeDBusSignature()); + }); + + group('WakelockPlusLinuxPlugin', () { + late MockDBusClient mockClient; + late MockDBusRemoteObject mockPortalObject; + late WakelockPlusLinuxPlugin plugin; + + setUp(() { + mockClient = MockDBusClient(); + mockPortalObject = MockDBusRemoteObject(); + plugin = WakelockPlusLinuxPlugin( + client: mockClient, + object: mockPortalObject, + appNameGetter: () async => 'TestApp', + ); + }); + + test('registerWith sets instance', () { + WakelockPlusLinuxPlugin.registerWith(); + expect( + WakelockPlusPlatformInterface.instance, + isA(), + ); + }); + + test('uses org.freedesktop.portal.Desktop', () { + final plugin = WakelockPlusLinuxPlugin(); + expect(plugin, isNotNull); + }); + + test('initially disabled', () async { + expect(await plugin.enabled, isFalse); + }); + + group('enable', () { + test('calls Inhibit with correct parameters', () async { + final mockResponse = DBusMethodSuccessResponse([ + DBusObjectPath('/org/freedesktop/portal/desktop/request/1_1/test'), + ]); + + when( + () => mockPortalObject.callMethod( + 'org.freedesktop.portal.Inhibit', + 'Inhibit', + any(), + replySignature: any(named: 'replySignature'), + ), + ).thenAnswer((_) async => mockResponse); + + await plugin.toggle(enable: true); + + verify( + () => mockPortalObject.callMethod( + 'org.freedesktop.portal.Inhibit', + 'Inhibit', + any( + that: isA() + .having((l) => l.length, 'length', 3) + .having((l) => l[0], 'window', isA()) + .having((l) => l[1], 'flags', isA()) + .having((l) => l[2], 'options', isA()), + ), + replySignature: DBusSignature('o'), + ), + ).called(1); + + expect(await plugin.enabled, isTrue); + }); + + test('flags are set to 8 (Idle)', () async { + final mockResponse = DBusMethodSuccessResponse([ + DBusObjectPath('/org/freedesktop/portal/desktop/request/1_1/test'), + ]); + + when( + () => mockPortalObject.callMethod( + any(), + any(), + any(), + replySignature: any(named: 'replySignature'), + ), + ).thenAnswer((_) async => mockResponse); + + await plugin.toggle(enable: true); + + final captured = + verify( + () => mockPortalObject.callMethod( + any(), + any(), + captureAny(), + replySignature: any(named: 'replySignature'), + ), + ).captured.single + as List; + + final flags = captured[1] as DBusUint32; + expect(flags.value, equals(8)); // 8 = Idle flag + }); + + test('includes reason in options', () async { + final mockResponse = DBusMethodSuccessResponse([ + DBusObjectPath('/org/freedesktop/portal/desktop/request/1_1/test'), + ]); + + when( + () => mockPortalObject.callMethod( + any(), + any(), + any(), + replySignature: any(named: 'replySignature'), + ), + ).thenAnswer((_) async => mockResponse); + + await plugin.toggle(enable: true); + + final captured = + verify( + () => mockPortalObject.callMethod( + any(), + any(), + captureAny(), + replySignature: any(named: 'replySignature'), + ), + ).captured.single + as List; + + final options = captured[2] as DBusDict; + expect(options.children.containsKey(DBusString('reason')), isTrue); + }); + }); + + group('disable', () { + test('calls Request.Close and clears state', () async { + final handlePath = DBusObjectPath( + '/org/freedesktop/portal/desktop/request/1_1/test', + ); + final mockInhibitResponse = DBusMethodSuccessResponse([handlePath]); + final mockCloseResponse = DBusMethodSuccessResponse([]); + + when( + () => mockPortalObject.callMethod( + 'org.freedesktop.portal.Inhibit', + 'Inhibit', + any(), + replySignature: any(named: 'replySignature'), + ), + ).thenAnswer((_) async => mockInhibitResponse); + + // Mock the Close call on DBusClient + when( + () => mockClient.callMethod( + destination: 'org.freedesktop.portal.Desktop', + path: handlePath, + interface: 'org.freedesktop.portal.Request', + name: 'Close', + values: any(named: 'values'), + replySignature: DBusSignature.empty, + ), + ).thenAnswer((_) async => mockCloseResponse); + + // Enable first + await plugin.toggle(enable: true); + expect(await plugin.enabled, isTrue); + + // Now disable + await plugin.toggle(enable: false); + expect(await plugin.enabled, isFalse); + + // Verify Close was called + verify( + () => mockClient.callMethod( + destination: 'org.freedesktop.portal.Desktop', + path: handlePath, + interface: 'org.freedesktop.portal.Request', + name: 'Close', + values: [], + replySignature: DBusSignature.empty, + ), + ).called(1); + }); + + test('does nothing if not enabled', () async { + await plugin.toggle(enable: false); + expect(await plugin.enabled, isFalse); + verifyNever( + () => mockPortalObject.callMethod( + any(), + any(), + any(), + replySignature: any(named: 'replySignature'), + ), + ); + }); + }); + + group('enabled getter', () { + test('returns false when not enabled', () async { + expect(await plugin.enabled, isFalse); + }); + + test('returns true after enable', () async { + final mockResponse = DBusMethodSuccessResponse([ + DBusObjectPath('/org/freedesktop/portal/desktop/request/1_1/test'), + ]); + + when( + () => mockPortalObject.callMethod( + any(), + any(), + any(), + replySignature: any(named: 'replySignature'), + ), + ).thenAnswer((_) async => mockResponse); + + await plugin.toggle(enable: true); + expect(await plugin.enabled, isTrue); + }); + }); + + group('DBusClient reuse', () { + test('uses the same client instance for all operations', () { + final testClient = MockDBusClient(); + final plugin = WakelockPlusLinuxPlugin( + client: testClient, + appNameGetter: () async => 'TestApp', + ); + + // The plugin should use the same client internally + expect(plugin, isNotNull); + }); + + test('factory constructor creates single client', () { + // This test verifies that the factory constructor doesn't create + // multiple DBusClient instances + final plugin1 = WakelockPlusLinuxPlugin( + appNameGetter: () async => 'TestApp1', + ); + final plugin2 = WakelockPlusLinuxPlugin( + appNameGetter: () async => 'TestApp2', + ); + + // Each plugin should have its own client, but within a plugin, + // the client should be reused + expect(plugin1, isNotNull); + expect(plugin2, isNotNull); + }); + }); + }); +}