diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4d99e0f1..3826f9e3 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,7 @@ jobs: matrix: # os: [ubuntu-latest, windows-latest] os: [ubuntu-latest] + steps: - uses: actions/checkout@v3 - uses: subosito/flutter-action@v2 diff --git a/.vscode/settings.json b/.vscode/settings.json index 3dba4022..bd72f219 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,5 +19,6 @@ "editor.rulers": [ 90 ] - } + }, + "cmake.ignoreCMakeListsMissing": true } diff --git a/analysis_options.yaml b/analysis_options.yaml index 598a253d..d7508aae 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -37,6 +37,7 @@ analyzer: exclude: - "**/*.g.dart" - "**/*.freezed.dart" + - "packaging/**" # flatpak-builder dir will kill the analyzer formatter: trailing_commas: preserve diff --git a/assets/lib/linux/active_window_kde.js b/assets/lib/linux/active_window_kde.js new file mode 100644 index 00000000..c479ff9d --- /dev/null +++ b/assets/lib/linux/active_window_kde.js @@ -0,0 +1,43 @@ +function print(str) { + console.info('Nyrna KDE Wayland: ' + str); +} + +print('Setting up persistent active window listener'); + +function windowToJson(window) { + return JSON.stringify({ + caption: window.caption, + pid: window.pid, + internalId: window.internalId, + }); +} + +function sendActiveWindowUpdate(window) { + if (!window) { + print('No active window'); + return; + } + + let windowJson = windowToJson(window); + + callDBus( + 'codes.merritt.Nyrna', + '/', + 'codes.merritt.Nyrna', + 'updateActiveWindow', + windowJson, + (result) => { + if (result) { + print('Updated active window on DBus'); + } else { + print('Failed to update active window on DBus'); + } + } + ); +} + +// Send the current active window immediately on load. +sendActiveWindowUpdate(workspace.activeWindow); + +// Listen for every subsequent focus change. +workspace.windowActivated.connect(sendActiveWindowUpdate); diff --git a/assets/lib/linux/list_windows_kde.js b/assets/lib/linux/list_windows_kde.js new file mode 100644 index 00000000..c139a3c2 --- /dev/null +++ b/assets/lib/linux/list_windows_kde.js @@ -0,0 +1,73 @@ +// https://unix.stackexchange.com/a/706478/379240 + +function print(str) { + console.info('Nyrna: ' + str); +} + +let windows = workspace.windowList(); +print('Found ' + windows.length + ' windows'); + +function updateWindowsOnDBus(windows) { + let windowsList = []; + + for (let window of windows) { + windowsList.push({ + caption: window.caption, + pid: window.pid, + internalId: window.internalId, + onCurrentDesktop: isWindowOnCurrentDesktop(window), + }); + } + + callDBus( + 'codes.merritt.Nyrna', + '/', + 'codes.merritt.Nyrna', + 'updateWindows', + JSON.stringify(windowsList), + (result) => { + if (result) { + print('Successfully updated windows on DBus'); + } else { + print('Failed to update windows on DBus'); + } + } + ); +} + +function isWindowOnCurrentDesktop(window) { + let windowDesktops = Object.values(window.desktops); + let windowIsOnCurrentDesktop = window.onAllDesktops; + + if (!windowIsOnCurrentDesktop) { + for (let windowDesktop of windowDesktops) { + if (windowDesktop.id === workspace.currentDesktop.id) { + windowIsOnCurrentDesktop = true; + break; + } else { + windowIsOnCurrentDesktop = false; + } + } + } + + return windowIsOnCurrentDesktop; +} + +updateWindowsOnDBus(windows); + +workspace.currentDesktopChanged.connect(() => { + print('Current desktop changed'); + updateWindowsOnDBus(windows); +}); + +workspace.windowAdded.connect(window => { + print('Window added: ' + window.caption); + windows.push(window); + updateWindowsOnDBus(windows); +}); + +workspace.windowRemoved.connect(window => { + print('Window removed: ' + window.caption); + windows = windows.filter(w => w.internalId !== window.internalId); + updateWindowsOnDBus(windows); +}); diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 00000000..fa0b357c --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/active_window/src/active_window.dart b/lib/active_window/src/active_window.dart index 22abdf3c..18446072 100644 --- a/lib/active_window/src/active_window.dart +++ b/lib/active_window/src/active_window.dart @@ -86,13 +86,28 @@ class ActiveWindow { log.i('Suspending'); for (int attempt = 0; attempt < _maxRetries; attempt++) { - final window = await _nativePlatform.activeWindow(); + await _nativePlatform.checkActiveWindow(); + final window = _nativePlatform.activeWindow; + + if (window == null) { + log.w('No active window found, retrying.'); + if (attempt < _maxRetries - 1) { + // checkActiveWindow() already waits internally (KDE Wayland uses a + // polling loop with a 2-second timeout; X11 is synchronous), so no + // additional delay is needed here. + continue; + } + log.e('Failed to find active window after $_maxRetries attempts.'); + return false; + } + final String executable = window.process.executable; if (executable == 'nyrna' || executable == 'nyrna.exe') { log.w('Active window is Nyrna, hiding and retrying.'); await _appWindow.hide(); await Future.delayed(const Duration(milliseconds: 500)); + // checkActiveWindow() will be called at the top of the next iteration. continue; } @@ -141,7 +156,7 @@ class ActiveWindow { return false; } - Future _minimize(int windowId) async { + Future _minimize(String windowId) async { final shouldMinimize = await _getShouldMinimize(); if (!shouldMinimize) return; @@ -150,7 +165,7 @@ class ActiveWindow { if (!minimized) log.e('Failed to minimize window.'); } - Future _restore(int windowId) async { + Future _restore(String windowId) async { final shouldRestore = await _getShouldMinimize(); if (!shouldRestore) return; diff --git a/lib/app/cubit/app_cubit.dart b/lib/app/cubit/app_cubit.dart index 68b52d9a..83ec9bfe 100644 --- a/lib/app/cubit/app_cubit.dart +++ b/lib/app/cubit/app_cubit.dart @@ -9,7 +9,6 @@ import 'package:url_launcher/url_launcher.dart' as url_launcher; import '../../logs/logs.dart'; import '../../native_platform/native_platform.dart'; -import '../../native_platform/src/linux/linux.dart'; import '../../storage/storage_repository.dart'; import '../../system_tray/system_tray.dart'; import '../../updates/updates.dart'; @@ -58,7 +57,7 @@ class AppCubit extends Cubit { /// blocking the UI, since none of the data fetched here is critical. Future _init() async { await _checkForFirstRun(); - await _checkLinuxSessionType(); + _checkLinuxSessionType(); await _fetchVersionData(); await _fetchReleaseNotes(); _listenToSystemTrayEvents(); @@ -74,10 +73,11 @@ class AppCubit extends Cubit { } /// For Linux, checks if the session type is Wayland. - Future _checkLinuxSessionType() async { + void _checkLinuxSessionType() { if (defaultTargetPlatform != TargetPlatform.linux) return; - final sessionType = await (_nativePlatform as Linux).sessionType(); + final sessionType = _nativePlatform.sessionType; + if (sessionType == null) return; final unknownSessionMsg = ''' @@ -85,9 +85,9 @@ Unable to determine session type. The XDG_SESSION_TYPE environment variable is s Please note that Wayland is not currently supported.'''; const waylandNotSupportedMsg = ''' -Wayland is not currently supported. +Wayland is currently supported only on KDE Plasma. -Only xwayland apps will be detected. +For other desktop environments, only xwayland apps will be detected. If Wayland support is important to you, consider voting on the issue: @@ -107,12 +107,19 @@ env QT_QPA_PLATFORM=xcb Otherwise, [consider signing in using X11 instead](https://docs.fedoraproject.org/en-US/quick-docs/configuring-xorg-as-default-gnome-session/).'''; - switch (sessionType) { - case 'wayland': - log.w(waylandNotSupportedMsg); - emit(state.copyWith(linuxSessionMessage: waylandNotSupportedMsg)); - return; - case 'x11': + emit(state.copyWith(sessionType: sessionType)); + + log.i('Session type: $sessionType'); + + switch (sessionType.displayProtocol) { + case DisplayProtocol.wayland: + if (sessionType.environment == DesktopEnvironment.kde) { + log.i('KDE Wayland session detected and is supported, proceeding.'); + } else { + log.w(waylandNotSupportedMsg); + emit(state.copyWith(linuxSessionMessage: waylandNotSupportedMsg)); + } + case DisplayProtocol.x11: break; default: log.w(unknownSessionMsg); @@ -211,4 +218,10 @@ Otherwise, [consider signing in using X11 instead](https://docs.fedoraproject.or return false; } } + + @override + Future close() async { + await _nativePlatform.dispose(); + await super.close(); + } } diff --git a/lib/app/cubit/app_state.dart b/lib/app/cubit/app_state.dart index d90e205e..67f6b5d0 100644 --- a/lib/app/cubit/app_state.dart +++ b/lib/app/cubit/app_state.dart @@ -7,6 +7,11 @@ abstract class AppState with _$AppState { /// session type is unknown. String? linuxSessionMessage, + /// The type of desktop session the user is running. + /// + /// Currently only used on Linux. + SessionType? sessionType, + /// True if this is the first run of the app. required bool firstRun, required String runningVersion, diff --git a/lib/apps_list/cubit/apps_list_cubit.dart b/lib/apps_list/cubit/apps_list_cubit.dart index 831b5868..4a2d989c 100644 --- a/lib/apps_list/cubit/apps_list_cubit.dart +++ b/lib/apps_list/cubit/apps_list_cubit.dart @@ -227,6 +227,7 @@ class AppsListCubit extends Cubit { _storage, ); + await _nativePlatform.checkActiveWindow(); return await activeWindow.toggle(); } diff --git a/lib/apps_list/models/interaction_error.dart b/lib/apps_list/models/interaction_error.dart index 0a73d6fe..926ead76 100644 --- a/lib/apps_list/models/interaction_error.dart +++ b/lib/apps_list/models/interaction_error.dart @@ -5,7 +5,7 @@ import '../enums.dart'; class InteractionError { final InteractionType interactionType; final ProcessStatus statusAfterInteraction; - final int windowId; + final String windowId; const InteractionError({ required this.interactionType, diff --git a/lib/loading/cubit/loading_cubit.dart b/lib/loading/cubit/loading_cubit.dart index 479418fa..316b4a4d 100644 --- a/lib/loading/cubit/loading_cubit.dart +++ b/lib/loading/cubit/loading_cubit.dart @@ -10,7 +10,7 @@ part 'loading_cubit.freezed.dart'; class LoadingCubit extends Cubit { final NativePlatform nativePlatform; - LoadingCubit() : nativePlatform = NativePlatform(), super(const LoadingInitial()) { + LoadingCubit(this.nativePlatform) : super(const LoadingInitial()) { checkDependencies(); } diff --git a/lib/loading/loading_page.dart b/lib/loading/loading_page.dart index fe67f416..9125e2eb 100644 --- a/lib/loading/loading_page.dart +++ b/lib/loading/loading_page.dart @@ -9,51 +9,69 @@ import 'loading.dart'; /// Intermediate loading screen while verifying that Nyrna's dependencies are /// available. If they are not an error message is shown, preventing a crash. -class LoadingPage extends StatelessWidget { +class LoadingPage extends StatefulWidget { static const id = 'loading_screen'; const LoadingPage({super.key}); + @override + State createState() => _LoadingPageState(); +} + +class _LoadingPageState extends State { + @override + void initState() { + super.initState(); + // BlocListener only fires for state *changes* after the widget subscribes. + // If checkDependencies() resolved before this widget was first mounted + // (e.g. on KDE Wayland where it returns immediately), the cubit will + // already be in LoadingSuccess when we first build. In that case the + // listener never fires, so we check the initial state here as well. + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final state = context.read().state; + if (state is LoadingSuccess) { + Navigator.pushReplacementNamed(context, AppsListPage.id); + } + }); + } + @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => LoadingCubit(), - lazy: false, - child: Scaffold( - body: Center( - child: BlocConsumer( - listener: (context, state) { - if (state is LoadingSuccess) { - Navigator.pushReplacementNamed(context, AppsListPage.id); - } - }, - builder: (context, state) { - switch (state) { - case LoadingError(): - return Padding( - padding: const EdgeInsets.all(16.0), - child: Card( - child: Container( - padding: const EdgeInsets.all(20.0), - child: MarkdownBody( - data: state.errorMsg, - onTapLink: (text, href, title) { - if (href == null) { - log.e('Broken link: $href'); - return; - } + return Scaffold( + body: Center( + child: BlocConsumer( + listener: (context, state) { + if (state is LoadingSuccess) { + Navigator.pushReplacementNamed(context, AppsListPage.id); + } + }, + builder: (context, state) { + switch (state) { + case LoadingError(): + return Padding( + padding: const EdgeInsets.all(16.0), + child: Card( + child: Container( + padding: const EdgeInsets.all(20.0), + child: MarkdownBody( + data: state.errorMsg, + onTapLink: (text, href, title) { + if (href == null) { + log.e('Broken link: $href'); + return; + } - AppCubit.instance.launchURL(href); - }, - ), + AppCubit.instance.launchURL(href); + }, ), ), - ); - default: - return const CircularProgressIndicator(); - } - }, - ), + ), + ); + default: + return const CircularProgressIndicator(); + } + }, ), ), ); diff --git a/lib/main.dart b/lib/main.dart index 25b64102..1478699c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'apps_list/apps_list.dart'; import 'argument_parser/argument_parser.dart'; import 'autostart/autostart_service.dart'; import 'hotkey/global/hotkey_service.dart'; +import 'loading/loading.dart'; import 'logs/logs.dart'; import 'native_platform/native_platform.dart'; import 'settings/cubit/settings_cubit.dart'; @@ -32,7 +33,6 @@ Future main(List args) async { final argParser = ArgumentParser()..parseArgs(args); final storage = await StorageRepository.initialize(Hive); - final nativePlatform = NativePlatform(); bool verbose = argParser.verbose; if (!verbose) { @@ -51,10 +51,13 @@ Future main(List args) async { final packageInfo = await PackageInfo.fromPlatform(); log.i('Starting Nyrna v${packageInfo.version}'); + final nativePlatform = await NativePlatform.initialize(); final processRepository = ProcessRepository.init(); final appWindow = AppWindow(storage); - appWindow.initialize(); + if (!argParser.toggleActiveWindow) { + await appWindow.initialize(); + } final activeWindow = ActiveWindow( appWindow, @@ -66,11 +69,13 @@ Future main(List args) async { // If we receive the toggle argument, suspend or resume the active // window and then exit without showing the GUI. if (argParser.toggleActiveWindow) { + await nativePlatform.checkActiveWindow(); await activeWindow.toggle(); // On Windows the program stays running in the background, so we don't want // to close these resources. if (defaultTargetPlatform == TargetPlatform.linux) { + await nativePlatform.dispose(); await storage.close(); LoggingManager.instance.close(); } @@ -118,6 +123,8 @@ Future main(List args) async { appVersion: AppVersion(packageInfo), ); + final loadingCubit = LoadingCubit(nativePlatform); + runApp( MultiRepositoryProvider( providers: [ @@ -127,6 +134,7 @@ Future main(List args) async { providers: [ BlocProvider.value(value: appCubit), BlocProvider.value(value: appsListCubit), + BlocProvider.value(value: loadingCubit), BlocProvider.value(value: settingsCubit), BlocProvider.value(value: themeCubit), ], diff --git a/lib/native_platform/native_platform.dart b/lib/native_platform/native_platform.dart index 1821462a..50f55d84 100644 --- a/lib/native_platform/native_platform.dart +++ b/lib/native_platform/native_platform.dart @@ -1,6 +1,7 @@ /// Nyrna's package to interactive with the native platform. library; +export 'src/linux/session_type.dart'; export 'src/native_platform.dart'; export 'src/process/process.dart'; export 'src/window.dart'; diff --git a/lib/native_platform/src/linux/active_window/active_window_service.dart b/lib/native_platform/src/linux/active_window/active_window_service.dart new file mode 100644 index 00000000..ef59d12d --- /dev/null +++ b/lib/native_platform/src/linux/active_window/active_window_service.dart @@ -0,0 +1,24 @@ +import '../../typedefs.dart'; +import '../linux.dart'; +import 'active_window_wayland.dart'; +import 'active_window_x11.dart'; + +/// Desktop and window manager agnostic interface to get the active window. +class ActiveWindowService { + final Linux _linux; + final RunFunction _run; + + ActiveWindowService(this._linux, this._run); + + /// Fetches the active window, which will be emitted by the [Linux] object. + Future fetch() async { + switch (_linux.sessionType.displayProtocol) { + case DisplayProtocol.wayland: + await ActiveWindowWayland.fetch(_linux); + case DisplayProtocol.x11: + await ActiveWindowX11.fetch(_linux, _run); + case DisplayProtocol.unknown: + throw UnimplementedError('Unknown display protocol'); + } + } +} diff --git a/lib/native_platform/src/linux/active_window/active_window_wayland.dart b/lib/native_platform/src/linux/active_window/active_window_wayland.dart new file mode 100644 index 00000000..8dcef768 --- /dev/null +++ b/lib/native_platform/src/linux/active_window/active_window_wayland.dart @@ -0,0 +1,96 @@ +import 'dart:async'; +import 'dart:convert'; + +import '../../../../logs/logging_manager.dart'; +import '../../../native_platform.dart'; +import '../linux.dart'; + +class ActiveWindowWayland { + /// KWin script name used to load / unload the persistent active-window + /// listener script. + static const String kdeScriptName = 'nyrna_get_active_window'; + + /// Subscription to D-Bus active-window update events. + /// + /// Set by [initialize] and cancelled by [dispose]. + static StreamSubscription? _subscription; + + /// Subscribe to [NyrnaDbus.activeWindowUpdates] so that every focus change + /// reported by the persistent KWin script immediately updates + /// [Linux.activeWindow]. + /// + /// Must be called once from [Linux.initialize] after the KWin scripts have + /// been loaded. + static void initialize(Linux linux) { + log.i('Setting up persistent active-window subscription (KDE Wayland).'); + _subscription = linux.nyrnaDbus.activeWindowUpdates.listen( + (windowString) async { + log.t('Active window update: $windowString'); + final windowJson = jsonDecode(windowString); + final pid = int.tryParse(windowJson['pid'].toString()); + if (pid == null) return; + final executable = await linux.getExecutableName(pid); + + final process = Process( + pid: pid, + executable: executable, + status: ProcessStatus.unknown, + ); + + final window = Window( + id: windowJson['internalId'].toString(), + process: process, + title: windowJson['caption'].toString(), + ); + + linux.activeWindow = window; + }, + ); + } + + /// Cancel the active-window subscription. + /// + /// Called from [Linux.dispose]. + static Future dispose() async { + await _subscription?.cancel(); + _subscription = null; + } + + /// Ensure [linux.activeWindow] is populated. + /// + /// Because the persistent KWin listener keeps [Linux.activeWindow] up-to-date + /// in the background, this method simply waits (up to [_timeout]) for the + /// field to become non-null. For X11 the field is set synchronously by + /// [ActiveWindowX11.fetch], so the wait is skipped immediately. + static const Duration _timeout = Duration(seconds: 2); + static const Duration _pollInterval = Duration(milliseconds: 100); + + static Future fetch(Linux linux) async { + switch (linux.sessionType.environment) { + case DesktopEnvironment.kde: + await _waitForActiveWindow(linux); + case DesktopEnvironment.gnome: + // TODO: A GNOME Shell extension approach would be needed for GNOME Wayland. + throw UnimplementedError('GNOME Wayland support is not yet implemented'); + default: + throw UnimplementedError( + 'ActiveWindowWayland.fetch is not implemented for ' + '${linux.sessionType.environment}', + ); + } + } + + static Future _waitForActiveWindow(Linux linux) async { + if (linux.activeWindow != null) return; + + log.d('Waiting for active window to be populated by KDE Wayland listener…'); + final deadline = DateTime.now().add(_timeout); + while (linux.activeWindow == null && DateTime.now().isBefore(deadline)) { + await Future.delayed(_pollInterval); + } + + if (linux.activeWindow == null) { + log.w('Timed out waiting for active window from KDE Wayland listener.'); + } + } +} diff --git a/lib/native_platform/src/linux/active_window/active_window_x11.dart b/lib/native_platform/src/linux/active_window/active_window_x11.dart new file mode 100644 index 00000000..6d08fafd --- /dev/null +++ b/lib/native_platform/src/linux/active_window/active_window_x11.dart @@ -0,0 +1,68 @@ +import '../../../native_platform.dart'; +import '../../typedefs.dart'; +import '../linux.dart'; + +/// Information on the active window in X11. +/// +/// Fetches the information when created. +class ActiveWindowX11 { + final Linux _linux; + final RunFunction _run; + + ActiveWindowX11._(this._linux, this._run); + + /// Returns a [Window] object representing the active window. + static Future fetch(Linux linux, RunFunction run) async { + final activeWindow = ActiveWindowX11._(linux, run); + await activeWindow._fetchActiveWindowInfo(); + } + + Future _fetchActiveWindowInfo() async { + final windowId = await _activeWindowId(); + if (windowId == '0') throw (Exception('No window id')); + + final pid = await _activeWindowPid(windowId); + if (pid == 0) throw (Exception('No pid')); + + final executable = await _linux.getExecutableName(pid); + final title = await _activeWindowTitle(); + + final process = Process( + pid: pid, + executable: executable, + status: ProcessStatus.unknown, + ); + + final window = Window( + id: windowId, + process: process, + title: title, + ); + + _linux.activeWindow = window; + } + + // Returns the unique hex ID of the active window as reported by xdotool. + Future _activeWindowId() async { + final result = await _run('xdotool', ['getactivewindow']); + final windowId = result.stdout.toString().trim(); + return windowId; + } + + Future _activeWindowPid(String windowId) async { + final result = await _run( + 'xdotool', + ['getwindowpid', windowId], + ); + final pid = int.tryParse(result.stdout.toString().trim()); + return pid ?? 0; + } + + Future _activeWindowTitle() async { + final result = await _run( + 'xdotool', + ['getactivewindow getwindowname'], + ); + return result.stdout.toString().trim(); + } +} diff --git a/lib/native_platform/src/linux/dbus/codes.merritt.Nyrna.xml b/lib/native_platform/src/linux/dbus/codes.merritt.Nyrna.xml new file mode 100644 index 00000000..c0d45372 --- /dev/null +++ b/lib/native_platform/src/linux/dbus/codes.merritt.Nyrna.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/lib/native_platform/src/linux/dbus/nyrna_dbus.dart b/lib/native_platform/src/linux/dbus/nyrna_dbus.dart new file mode 100644 index 00000000..ffc03de4 --- /dev/null +++ b/lib/native_platform/src/linux/dbus/nyrna_dbus.dart @@ -0,0 +1,155 @@ +// This file was generated using the following command and may be overwritten. +// dart-dbus generate-object lib/native_platform/src/linux/codes.merritt.Nyrna.xml + +import 'dart:async'; + +import 'package:dbus/dbus.dart'; + +import '../../../../logs/logs.dart'; + +class NyrnaDbus extends DBusObject { + /// Creates a new object to expose on [path]. + NyrnaDbus._({DBusObjectPath path = const DBusObjectPath.unchecked('/')}) : super(path); + + DBusClient? _client; + + static Future initialize() async { + final nyrnaDbus = NyrnaDbus._(); + await nyrnaDbus._registerNyrnaDbusObject(); + return nyrnaDbus; + } + + /// Register Nyrna's service on DBus. + /// + /// **Flatpak note**: A Flatpak sandbox must declare the following + /// finish-args to allow this registration and the KDE Wayland integration: + /// + /// ``` + /// --own-name=codes.merritt.Nyrna (to register this D-Bus service) + /// --talk-name=org.kde.KWin (to load/unload KWin scripts) + /// ``` + /// + /// `flatpak-spawn --host` (used for CLI tools like xdotool/wmctrl/kill) also + /// requires `--talk-name=org.freedesktop.Flatpak` unless the host D-Bus + /// service is already accessible via `--socket=session-bus`. + Future _registerNyrnaDbusObject() async { + _client = DBusClient.session(); + final client = _client!; + + DBusRequestNameReply result; + + try { + result = await client.requestName( + 'codes.merritt.Nyrna', + flags: { + DBusRequestNameFlag.allowReplacement, + DBusRequestNameFlag.doNotQueue, + DBusRequestNameFlag.replaceExisting, + }, + ); + } catch (e) { + log.e('Failed to request name: $e'); + rethrow; + } + + if (result != DBusRequestNameReply.primaryOwner) { + log.e('Failed to request name: $result'); + throw Exception('Failed to request name: $result'); + } + + await client.registerObject(this); + } + + /// The JSON of windows found by the companion KDE KWin script. + /// + /// This will be updated by the `updateWindows` method, and read externally by the + /// `Linux` class. + String windowsJson = ''; + + /// Called by the companion KDE KWin script to update the JSON of windows. + Future updateWindows(String windows) async { + windowsJson = windows; + + return DBusMethodSuccessResponse([ + const DBusBoolean(true), + ]); + } + + final _activeWindowController = StreamController.broadcast(); + Stream get activeWindowUpdates => _activeWindowController.stream; + + /// Called by the companion KDE KWin script to update the active window. + Future updateActiveWindow(String windowId) async { + log.t('Received active window update on DBus: $windowId'); + _activeWindowController.add(windowId); + return DBusMethodSuccessResponse([const DBusBoolean(true)]); + } + + @override + List introspect() { + return [ + DBusIntrospectInterface( + 'codes.merritt.Nyrna', + methods: [ + DBusIntrospectMethod('updateWindows'), + DBusIntrospectMethod('updateActiveWindow'), + ], + ), + ]; + } + + @override + Future handleMethodCall(DBusMethodCall methodCall) async { + log.t( + 'Received method call: ${methodCall.interface}.${methodCall.name}, signature: ${methodCall.signature}, values: ${methodCall.values}', + ); + + if (methodCall.interface != 'codes.merritt.Nyrna') { + return DBusMethodErrorResponse.unknownInterface(); + } + + switch (methodCall.name) { + case 'updateWindows': + if (methodCall.signature != DBusSignature('s')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return await updateWindows(methodCall.values[0].asString()); + case 'updateActiveWindow': + if (methodCall.signature != DBusSignature('s')) { + return DBusMethodErrorResponse.invalidArgs(); + } + return await updateActiveWindow(methodCall.values[0].asString()); + default: + return DBusMethodErrorResponse.unknownMethod(); + } + } + + @override + Future getProperty(String interface, String name) async { + if (interface == 'codes.merritt.Nyrna') { + return DBusMethodErrorResponse.unknownProperty(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } + + @override + Future setProperty( + String interface, + String name, + DBusValue value, + ) async { + if (interface == 'codes.merritt.Nyrna') { + return DBusMethodErrorResponse.unknownProperty(); + } else { + return DBusMethodErrorResponse.unknownProperty(); + } + } + + Future dispose() async { + await _activeWindowController.close(); + await _client?.releaseName('codes.merritt.Nyrna'); + await _client?.close(); + _client = null; + } +} diff --git a/lib/native_platform/src/linux/linux.dart b/lib/native_platform/src/linux/linux.dart index ad4fa9cf..a278a2e3 100644 --- a/lib/native_platform/src/linux/linux.dart +++ b/lib/native_platform/src/linux/linux.dart @@ -1,21 +1,134 @@ +import 'dart:async'; +import 'dart:convert'; import 'dart:io' as io; +import 'package:kwin/kwin.dart'; + import '../../../logs/logs.dart'; import '../native_platform.dart'; import '../process/models/process.dart'; import '../typedefs.dart'; import '../window.dart'; +import 'active_window/active_window_service.dart'; +import 'active_window/active_window_wayland.dart'; +import 'dbus/nyrna_dbus.dart'; +import 'session_type.dart'; + +export 'flatpak.dart'; +export 'session_type.dart'; /// System-level or non-app executables. Nyrna shouldn't show these. const List _filteredWindows = [ 'nyrna', + // Remove any instances of plasmashell, which is the KDE desktop. + // It appears to show up for each monitor and virtual desktop. + 'plasmashell', + 'xwaylandvideobridge', ]; /// Interact with the native Linux operating system. class Linux implements NativePlatform { + /// Name of the KWin script that fetches info when running KDE Wayland. + static const String _kdeWaylandScriptName = 'nyrna_wayland'; + + /// Path to the KWin script that fetches info when running KDE Wayland. + /// + /// The path is an argument to allow for dependency injection in tests. + final String _kdeWaylandScriptPath; + + /// Path to the persistent active-window KWin script for KDE Wayland. + /// + /// The path is an argument to allow for dependency injection in tests. + final String _activeWindowScriptPath; + + /// Directory in which temporary KWin scripts (minimize/restore) are written. + /// + /// Must be a path that is accessible to KWin on the host filesystem. In a + /// Flatpak sandbox the system temp directory is private to the container, so + /// the application-support directory (visible to the host via bind-mount) is + /// used instead. Defaults to [io.Directory.systemTemp] when empty. + final String _tempScriptDir; + + final KWin kwin; + final NyrnaDbus nyrnaDbus; final RunFunction _run; - Linux(this._run); + @override + final SessionType sessionType; + StreamSubscription? _scriptOutputSubscription; + Linux._( + this._kdeWaylandScriptPath, + this._activeWindowScriptPath, + this._tempScriptDir, + this.kwin, + this.nyrnaDbus, + this._run, + this.sessionType, + ); + + @override + Window? activeWindow; + + static Future initialize( + RunFunction run, { + String kdeWaylandScriptPath = '', + String activeWindowScriptPath = '', + KWin? kwin, // Allows overriding for testing. + NyrnaDbus? nyrnaDbus, // Allows overriding for testing. + SessionType? sessionOverride, // Allows overriding for testing. + String tempScriptDir = '', + }) async { + final dbusService = nyrnaDbus ?? await NyrnaDbus.initialize(); + final kwinService = kwin ?? KWin(); + final session = sessionOverride ?? await SessionType.fromEnvironment(); + final linux = Linux._( + kdeWaylandScriptPath, + activeWindowScriptPath, + tempScriptDir, + kwinService, + dbusService, + run, + session, + ); + + if (session.displayProtocol == DisplayProtocol.wayland && + session.environment == DesktopEnvironment.kde) { + ActiveWindowWayland.initialize(linux); + await linux._loadKdeWaylandScript(); + } + + return linux; + } + + Future _loadKdeWaylandScript() async { + log.i('Loading KWin scripts for KDE Wayland.'); + log.i(' List-windows script: $_kdeWaylandScriptPath'); + log.i(' Active-window script: $_activeWindowScriptPath'); + + await kwin.loadScript(_kdeWaylandScriptPath, _kdeWaylandScriptName); + await kwin.loadScript(_activeWindowScriptPath, ActiveWindowWayland.kdeScriptName); + + // Log KWin script output prefixed by 'Nyrna'. + _scriptOutputSubscription = kwin.scriptOutput + .where((event) => event.contains('Nyrna')) + .listen((event) { + log.t('KWin script output: $event'); + }); + + // Poll until the KWin list-windows script has populated the window list, + // so the UI is not shown before data is ready. Using a polling loop + // instead of a fixed delay allows the UI to appear as soon as the scripts + // respond, and avoids unnecessary waiting when KWin is fast. + const scriptTimeout = Duration(seconds: 5); + const pollInterval = Duration(milliseconds: 100); + final deadline = DateTime.now().add(scriptTimeout); + while (nyrnaDbus.windowsJson.isEmpty && DateTime.now().isBefore(deadline)) { + await Future.delayed(pollInterval); + } + if (nyrnaDbus.windowsJson.isEmpty) { + log.w('Timed out waiting for KWin script to populate the window list.'); + } + } int? _desktop; @@ -34,6 +147,71 @@ class Linux implements NativePlatform { // Gets all open windows as reported by wmctrl. @override Future> windows({bool showHidden = false}) async { + switch (sessionType.displayProtocol) { + case DisplayProtocol.wayland: + return await _getWindowsWayland(showHidden); + case DisplayProtocol.x11: + return await _getWindowsX11(showHidden); + default: + return Future.error('Unknown session type: $sessionType'); + } + } + + Future> _getWindowsWayland(bool showHidden) async { + switch (sessionType.environment) { + case DesktopEnvironment.kde: + return await _getWindowsKdeWayland(showHidden); + default: + // For non-KDE Wayland (GNOME, etc.), fall back to X11 detection + // so xwayland apps are still visible. + log.w( + 'Wayland on ${sessionType.environment} is not natively supported. ' + 'Falling back to X11 detection for xwayland apps.', + ); + return await _getWindowsX11(showHidden); + } + } + + Future> _getWindowsKdeWayland(bool showHidden) async { + if (nyrnaDbus.windowsJson.isEmpty) { + log.w('No windows found from KDE Wayland'); + return []; + } + + final windowsJson = jsonDecode(nyrnaDbus.windowsJson); + final windows = []; + + for (var window in windowsJson) { + final onCurrentDesktop = window['onCurrentDesktop'] == true; + if (!onCurrentDesktop && !showHidden) continue; + + final windowId = window['internalId']; + final windowTitle = window['caption']; + final pid = window['pid']; + final executable = await getExecutableName(pid); + if (_filteredWindows.contains(executable)) continue; + + final process = Process( + pid: pid, + executable: executable, + status: ProcessStatus.unknown, + ); + + windows.add( + Window( + id: windowId, + process: process, + title: windowTitle, + ), + ); + } + + log.i('Windows from KDE Wayland: found ${windows.length} windows'); + + return windows; + } + + Future> _getWindowsX11(bool showHidden) async { await currentDesktop(); final wmctrlOutput = await _run('bash', ['-c', 'wmctrl -lp']); @@ -50,10 +228,6 @@ class Linux implements NativePlatform { if (window != null) windows.add(window); } - // Remove any instances of plasmashell, which is the KDE desktop. - // It appears to show up on X11 sessions, for each monitor and virtual desktop. - windows.removeWhere((window) => window.process.executable == 'plasmashell'); - return windows; } @@ -79,7 +253,7 @@ class Linux implements NativePlatform { final id = int.tryParse(parts[0]); if ((pid == null) || (id == null)) return null; - final executable = await _getExecutableName(pid); + final executable = await getExecutableName(pid); if (_filteredWindows.contains(executable)) return null; final process = Process( @@ -89,65 +263,32 @@ class Linux implements NativePlatform { ); final title = parts.sublist(4).join(' '); - return Window(id: id, process: process, title: title); + return Window(id: '$id', process: process, title: title); } - Future _getExecutableName(int pid) async { + Future getExecutableName(int pid) async { final result = await _run('readlink', ['/proc/$pid/exe']); final executable = result.stdout.toString().split('/').last.trim(); return executable; } @override - Future activeWindow() async { - final windowId = await _activeWindowId(); - if (windowId == 0) throw (Exception('No window id')); - - final pid = await _activeWindowPid(windowId); - if (pid == 0) throw (Exception('No pid')); - - final executable = await _getExecutableName(pid); - final process = Process( - pid: pid, - executable: executable, - status: ProcessStatus.unknown, - ); - final windowTitle = await _activeWindowTitle(); - - return Window( - id: windowId, - process: process, - title: windowTitle, - ); - } - - // Returns the unique hex ID of the active window as reported by xdotool. - Future _activeWindowId() async { - final result = await _run('xdotool', ['getactivewindow']); - final windowId = int.tryParse(result.stdout.toString().trim()); - return windowId ?? 0; - } - - Future _activeWindowPid(int windowId) async { - final result = await _run( - 'xdotool', - ['getwindowpid', '$windowId'], - ); - final pid = int.tryParse(result.stdout.toString().trim()); - return pid ?? 0; + Future checkActiveWindow() async { + final activeWindowService = ActiveWindowService(this, _run); + await activeWindowService.fetch(); } - Future _activeWindowTitle() async { - final result = await _run( - 'xdotool', - ['getactivewindow getwindowname'], - ); - return result.stdout.toString().trim(); - } - - // Verify wmctrl and xdotool are present on the system. + // Verify required tools are present on the system. @override Future checkDependencies() async { + if (sessionType.displayProtocol == DisplayProtocol.wayland && + sessionType.environment == DesktopEnvironment.kde) { + // KDE Wayland uses KWin D-Bus scripting. If initialization succeeded, + // the interface is available; no additional binary dependencies to check. + return true; + } + + // X11 (or unknown protocol) — verify wmctrl and xdotool are present. final xdotoolResult = await _run('bash', [ '-c', 'command -v xdotool >/dev/null 2>&1 || { echo >&2 "xdotool is required but it\'s not installed."; exit 1; }', @@ -175,30 +316,176 @@ Make sure these are installed on your host system.''', return dependenciesAvailable; } - /// Return is `x11` or `wayland` depending on which session type is running. - Future sessionType() async { - final sessionType = io.Platform.environment['XDG_SESSION_TYPE']; - log.i('Current session type: $sessionType'); - return sessionType!; - } - @override - Future minimizeWindow(int windowId) async { + Future minimizeWindow(String windowId) async { log.i('Minimizing window with id $windowId'); + + switch (sessionType.displayProtocol) { + case DisplayProtocol.wayland: + switch (sessionType.environment) { + case DesktopEnvironment.kde: + return await _minimizeWindowWaylandKDE(windowId); + default: + // For non-KDE Wayland (e.g. GNOME), window listing falls back to + // X11/wmctrl, so window IDs are X11 integers — use xdotool. + return await _minimizeWindowX11(windowId); + } + case DisplayProtocol.x11: + return await _minimizeWindowX11(windowId); + default: + return Future.error('Unknown session type: $sessionType.displayProtocol'); + } + } + + Future _minimizeWindowX11(String windowId) async { final result = await _run( 'xdotool', - ['windowminimize', '$windowId'], + ['windowminimize', windowId], ); return (result.stderr == '') ? true : false; } + static const String kwinMinimizeRestoreScript = ''' +(function() { + function print(str) { + console.info('Nyrna: ' + str); + } + + let windows = workspace.windowList(); + let targetWindowId = "%windowId%"; + // Normalize UUIDs by stripping curly braces to handle format differences + // between JSON.stringify() and QUuid.toString() in KWin's JS engine. + let normalizeId = (id) => id.toString().replace(/[{}]/g, '').toLowerCase(); + let targetNormalized = normalizeId(targetWindowId); + let targetWindow = windows.find(w => normalizeId(w.internalId) === targetNormalized); + + if (!targetWindow) { + print('Window with id ' + targetWindowId + ' not found'); + return; + } + + let shouldMinimize = %minimize%; + targetWindow.minimized = shouldMinimize; + + print('Window with id ' + targetWindowId + ' ' + (shouldMinimize ? 'minimized' : 'restored')); + + if (!shouldMinimize) { + workspace.activeWindow = targetWindow; + } +})(); +'''; + + Future _minimizeWindowWaylandKDE(String windowId) async { + if (!_isValidWindowId(windowId)) { + log.e('Invalid windowId for minimize: $windowId'); + return false; + } + + // Create a javascript file in a host-visible directory so KWin can load it. + // The system temp directory is private to the Flatpak sandbox, so we use + // _tempScriptDir (the application-support directory) instead when set. + final scriptDir = _tempScriptDir.isNotEmpty + ? _tempScriptDir + : io.Directory.systemTemp.path; + final scriptFile = io.File('$scriptDir/nyrna_minimize.js'); + await scriptFile.writeAsString( + kwinMinimizeRestoreScript + .replaceAll('%windowId%', windowId) + .replaceAll('%minimize%', 'true'), + ); + + try { + await kwin.loadScript(scriptFile.path, 'nyrna_minimize'); + } catch (e, st) { + log.e('Failed to load minimize script', error: e, stackTrace: st); + return false; + } + + // callreconfigure() is fire-and-forget (noReplyExpected), so KWin executes + // the script asynchronously after loadScript() returns. Wait briefly before + // deleting the temp file to ensure KWin has had time to read it. + await Future.delayed(const Duration(milliseconds: 500)); + await scriptFile.delete(); + return true; + } + @override - Future restoreWindow(int windowId) async { + Future restoreWindow(String windowId) async { log.i('Restoring window with id $windowId'); + + switch (sessionType.displayProtocol) { + case DisplayProtocol.wayland: + switch (sessionType.environment) { + case DesktopEnvironment.kde: + return await _restoreWindowWaylandKDE(windowId); + default: + // For non-KDE Wayland (e.g. GNOME), window listing falls back to + // X11/wmctrl, so window IDs are X11 integers — use xdotool. + return await _restoreWindowX11(windowId); + } + case DisplayProtocol.x11: + return await _restoreWindowX11(windowId); + default: + return Future.error('Unknown session type: $sessionType.displayProtocol'); + } + } + + Future _restoreWindowX11(String windowId) async { final result = await _run( 'xdotool', - ['windowactivate', '$windowId'], + ['windowactivate', windowId], ); return (result.stderr == '') ? true : false; } + + /// Returns true if [windowId] is a valid UUID or hex window ID. + /// + /// This guards against JS injection into KWin scripts from a malformed ID. + bool _isValidWindowId(String windowId) { + return RegExp(r'^\{?[0-9a-fA-F-]+\}?$').hasMatch(windowId); + } + + Future _restoreWindowWaylandKDE(String windowId) async { + if (!_isValidWindowId(windowId)) { + log.e('Invalid windowId for restore: $windowId'); + return false; + } + + // Create a javascript file in a host-visible directory so KWin can load it. + final scriptDir = _tempScriptDir.isNotEmpty + ? _tempScriptDir + : io.Directory.systemTemp.path; + final scriptFile = io.File('$scriptDir/nyrna_restore.js'); + await scriptFile.writeAsString( + kwinMinimizeRestoreScript + .replaceAll('%windowId%', windowId) + .replaceAll('%minimize%', 'false'), + ); + + try { + await kwin.loadScript(scriptFile.path, 'nyrna_restore'); + } catch (e, st) { + log.e('Failed to load restore script', error: e, stackTrace: st); + return false; + } + + // callreconfigure() is fire-and-forget (noReplyExpected), so KWin executes + // the script asynchronously after loadScript() returns. Wait briefly before + // deleting the temp file to ensure KWin has had time to read it. + await Future.delayed(const Duration(milliseconds: 500)); + await scriptFile.delete(); + return true; + } + + @override + Future dispose() async { + await _scriptOutputSubscription?.cancel(); + await kwin.unloadScript(_kdeWaylandScriptName); + await kwin.unloadScript(ActiveWindowWayland.kdeScriptName); + await kwin.unloadScript('nyrna_minimize'); + await kwin.unloadScript('nyrna_restore'); + await kwin.dispose(); + await nyrnaDbus.dispose(); + await ActiveWindowWayland.dispose(); + } } diff --git a/lib/native_platform/src/linux/session_type.dart b/lib/native_platform/src/linux/session_type.dart new file mode 100644 index 00000000..77688ba5 --- /dev/null +++ b/lib/native_platform/src/linux/session_type.dart @@ -0,0 +1,90 @@ +import 'dart:io' as io; + +import '../../../logs/logging_manager.dart'; + +/// Enum representing the display protocol used by the Linux session. +enum DisplayProtocol { + wayland, + x11, + unknown; + + /// Create DisplayProtocol from string. + static DisplayProtocol fromString(String protocol) { + return DisplayProtocol.values.firstWhere( + (e) => e.name.toLowerCase() == protocol.toLowerCase(), + orElse: () => DisplayProtocol.unknown, + ); + } +} + +/// Enum representing the Desktop Environment. +enum DesktopEnvironment { + /// KDE Plasma desktop environment. + kde, + + /// GNOME desktop environment. + gnome, + + /// XFCE desktop environment. + xfce, + + /// Unknown desktop environment. + unknown; + + /// Create DesktopEnvironment from string. + static DesktopEnvironment fromString(String environment) { + return DesktopEnvironment.values.firstWhere( + (e) => e.name.toLowerCase() == environment.toLowerCase(), + orElse: () => DesktopEnvironment.unknown, + ); + } +} + +/// Information about the current Linux desktop session. +class SessionType { + const SessionType({ + required this.displayProtocol, + required this.environment, + }); + + final DisplayProtocol displayProtocol; + final DesktopEnvironment environment; + + /// Create a SessionType from environment variables. + /// + /// Returns a [SessionType] with [DisplayProtocol.unknown] and + /// [DesktopEnvironment.unknown] if the required environment variables are + /// not set, rather than throwing an error. + static Future fromEnvironment() async { + final displayProtocolEnv = io.Platform.environment['XDG_SESSION_TYPE']; + if (displayProtocolEnv == null || displayProtocolEnv.isEmpty) { + log.w('XDG_SESSION_TYPE is not set, defaulting to unknown'); + return const SessionType( + displayProtocol: DisplayProtocol.unknown, + environment: DesktopEnvironment.unknown, + ); + } + + final displayProtocol = DisplayProtocol.fromString(displayProtocolEnv); + + final environmentEnv = io.Platform.environment['XDG_CURRENT_DESKTOP']; + if (environmentEnv == null || environmentEnv.isEmpty) { + log.w('XDG_CURRENT_DESKTOP is not set, defaulting to unknown'); + return SessionType( + displayProtocol: displayProtocol, + environment: DesktopEnvironment.unknown, + ); + } + + final environment = DesktopEnvironment.fromString(environmentEnv); + return SessionType( + displayProtocol: displayProtocol, + environment: environment, + ); + } + + @override + String toString() { + return 'SessionType(displayProtocol: $displayProtocol, environment: $environment)'; + } +} diff --git a/lib/native_platform/src/native_platform.dart b/lib/native_platform/src/native_platform.dart index d29ee694..2305e637 100644 --- a/lib/native_platform/src/native_platform.dart +++ b/lib/native_platform/src/native_platform.dart @@ -1,8 +1,8 @@ import 'dart:io' as io; import 'package:helpers/helpers.dart'; +import 'package:path_provider/path_provider.dart'; -import 'linux/flatpak.dart'; import 'linux/linux.dart'; import 'win32/win32.dart'; import 'window.dart'; @@ -13,10 +13,26 @@ import 'window.dart'; /// Used by [Linux] and [Win32]. abstract class NativePlatform { // Return correct subtype depending on the current operating system. - factory NativePlatform() { + static Future initialize() async { if (io.Platform.isLinux) { final runFunction = (runningInFlatpak()) ? flatpakRun : io.Process.run; - return Linux(runFunction); + final kdeWaylandScriptPath = await _getKdeWaylandScriptPath(); + final activeWindowScriptPath = await _getActiveWindowKdeScriptPath(); + + // Derive the host-visible script directory from the already-resolved + // script path (both scripts live in the same app-support directory). + // Falls back to empty string in tests, which causes Linux to use the + // system temp directory (acceptable outside Flatpak). + final tempScriptDir = kdeWaylandScriptPath.isNotEmpty + ? io.File(kdeWaylandScriptPath).parent.path + : ''; + + return await Linux.initialize( + runFunction, + kdeWaylandScriptPath: kdeWaylandScriptPath, + activeWindowScriptPath: activeWindowScriptPath, + tempScriptDir: tempScriptDir, + ); } else { return Win32(); } @@ -32,14 +48,56 @@ abstract class NativePlatform { Future> windows({bool showHidden}); /// The active, foreground window. - Future activeWindow(); + Window? activeWindow; + + /// Update our knowledge of the active window. + Future checkActiveWindow(); /// Verify dependencies are present on the system. Future checkDependencies(); /// Minimize the window with the given [windowId]. - Future minimizeWindow(int windowId); + Future minimizeWindow(String windowId); /// Restore / unminimize the window with the given [windowId]. - Future restoreWindow(int windowId); + Future restoreWindow(String windowId); + + /// Safely dispose of resources when done. + Future dispose(); + + /// The session type of the current platform, if applicable. + /// + /// Returns `null` on non-Linux platforms. + SessionType? get sessionType => null; +} + +/// Get the path to the KDE Wayland script. +/// +/// The script will be kept in the application support directory so we can provide the +/// path to it over D-Bus, which can't be done when the script is in the app bundle. +/// +/// The script will be copied over on every launch to ensure it's up-to-date. +Future _getKdeWaylandScriptPath() async { + if (io.Platform.environment['FLUTTER_TEST'] == 'true') return ''; + + final dataDir = await getApplicationSupportDirectory(); + final tempFile = await assetToTempDir('assets/lib/linux/list_windows_kde.js'); + final file = io.File('${dataDir.path}${io.Platform.pathSeparator}list_windows_kde.js'); + await tempFile.copy(file.path); + return file.path; +} + +/// Get the path to the persistent active-window KDE KWin script. +/// +/// Copies the bundled asset to the application support directory so that KWin +/// can be given a filesystem path via D-Bus. The file is overwritten on every +/// launch to keep it in sync with the bundled version. +Future _getActiveWindowKdeScriptPath() async { + if (io.Platform.environment['FLUTTER_TEST'] == 'true') return ''; + + final dataDir = await getApplicationSupportDirectory(); + final tempFile = await assetToTempDir('assets/lib/linux/active_window_kde.js'); + final file = io.File('${dataDir.path}${io.Platform.pathSeparator}active_window_kde.js'); + await tempFile.copy(file.path); + return file.path; } diff --git a/lib/native_platform/src/process/repository/process_repository.dart b/lib/native_platform/src/process/repository/process_repository.dart index e1f9155b..1e0970ff 100644 --- a/lib/native_platform/src/process/repository/process_repository.dart +++ b/lib/native_platform/src/process/repository/process_repository.dart @@ -2,7 +2,7 @@ import 'dart:io' as io; import 'package:helpers/helpers.dart'; -import '../../linux/flatpak.dart'; +import '../../linux/linux.dart'; import '../models/process.dart'; import 'src/linux_process_repository.dart'; import 'src/win32_process_repository.dart'; diff --git a/lib/native_platform/src/win32/win32.dart b/lib/native_platform/src/win32/win32.dart index 2343ce34..150840e6 100644 --- a/lib/native_platform/src/win32/win32.dart +++ b/lib/native_platform/src/win32/win32.dart @@ -6,6 +6,7 @@ import 'package:ffi/ffi.dart'; import 'package:win32/win32.dart'; import '../../../logs/logs.dart'; +import '../linux/session_type.dart'; import '../native_platform.dart'; import '../process/models/process.dart'; import '../window.dart'; @@ -31,6 +32,9 @@ const List _filteredWindows = [ /// /// Requires many syscalls to the win32 API. class Win32 implements NativePlatform { + @override + SessionType? get sessionType => null; + // Not available on Windows, so just return 0 always. @override Future currentDesktop() async => 0; @@ -43,18 +47,29 @@ class Win32 implements NativePlatform { } @override - Future activeWindow() async { + Window? activeWindow; + + @override + Future checkActiveWindow() async { final windowId = await _activeWindowId(); final pid = await _pidFromWindowId(windowId); final executable = await getExecutableName(pid); + final process = Process( pid: pid, executable: executable, status: ProcessStatus.unknown, ); + final title = getWindowTitle(windowId); - return Window(id: windowId, process: process, title: title); + final window = Window( + id: windowId.toString(), + process: process, + title: title, + ); + + activeWindow = window; } Future _activeWindowId() async => GetForegroundWindow(); @@ -100,16 +115,16 @@ class Win32 implements NativePlatform { } @override - Future minimizeWindow(int windowId) async { + Future minimizeWindow(String windowId) async { log.i('Minimizing window with id $windowId'); - ShowWindow(windowId, SW_FORCEMINIMIZE); + ShowWindow(int.parse(windowId), SW_FORCEMINIMIZE); return true; // [ShowWindow] return value doesn't confirm success. } @override - Future restoreWindow(int windowId) async { + Future restoreWindow(String windowId) async { log.i('Restoring window with id $windowId'); - ShowWindow(windowId, SW_RESTORE); + ShowWindow(int.parse(windowId), SW_RESTORE); return true; // [ShowWindow] return value doesn't confirm success. } @@ -147,6 +162,11 @@ class Win32 implements NativePlatform { return executable; } + + @override + Future dispose() async { + // No cleanup needed. + } } // Static methods required because the win32 callback is required to be static. @@ -171,7 +191,7 @@ class WindowBuilder { final correctedWindows = []; for (var window in _windows) { - final process = await Win32().processFromWindowId(window.id); + final process = await Win32().processFromWindowId(int.parse(window.id)); if (_filteredWindows.contains(process.executable)) continue; @@ -212,7 +232,7 @@ class WindowBuilder { _windows.add( Window( - id: hWnd, + id: hWnd.toString(), process: const Process( executable: '', pid: 0, diff --git a/lib/native_platform/src/window.dart b/lib/native_platform/src/window.dart index 4c22ddb8..092b06b4 100644 --- a/lib/native_platform/src/window.dart +++ b/lib/native_platform/src/window.dart @@ -9,7 +9,9 @@ part 'window.freezed.dart'; abstract class Window with _$Window { const factory Window({ /// The unique window ID number associated with this window. - required int id, + /// + /// Can be either a number or a UUID (e.g. on KDE Wayland). + required String id, /// The process associated with this window. required Process process, diff --git a/packaging/linux/flatpak/build-local.sh b/packaging/linux/flatpak/build-local.sh index c75d0589..1f5c00ce 100755 --- a/packaging/linux/flatpak/build-local.sh +++ b/packaging/linux/flatpak/build-local.sh @@ -73,6 +73,15 @@ tar -czf "$REPO_ROOT/Nyrna-Linux-Portable.tar.gz" -C "$BUNDLE_DIR" . echo "==> Building Flatpak..." cd "$SCRIPT_DIR" +# Clean up any stale rofiles-fuse mounts from a previous interrupted build. +echo "==> Cleaning up any stale FUSE mounts..." +for mount in "$SCRIPT_DIR/.flatpak-builder/rofiles"/rofiles-*; do + if [ -d "$mount" ]; then + fusermount3 -uz "$mount" 2>/dev/null || fusermount -uz "$mount" 2>/dev/null || true + rmdir "$mount" 2>/dev/null || true + fi +done + flatpak-builder \ --force-clean \ --user \ diff --git a/packaging/linux/flatpak/codes.merritt.Nyrna.dev.yml b/packaging/linux/flatpak/codes.merritt.Nyrna.dev.yml index 2c7218aa..b8988b8e 100644 --- a/packaging/linux/flatpak/codes.merritt.Nyrna.dev.yml +++ b/packaging/linux/flatpak/codes.merritt.Nyrna.dev.yml @@ -17,13 +17,16 @@ command: nyrna separate-locales: false finish-args: - --share=ipc - - --socket=x11 + - --socket=wayland + - --socket=fallback-x11 - --device=dri - --socket=pulseaudio - --share=network - --talk-name=org.freedesktop.Flatpak # Access host processes # Tray icon support - --talk-name=org.kde.StatusNotifierWatcher + - --talk-name=org.kde.KWin # Get window info from KWin for Wayland support + - --own-name=codes.merritt.Nyrna # Required for NyrnaDbus.initialize() D-Bus RequestName modules: - name: keybinder3 buildsystem: autotools diff --git a/pubspec.lock b/pubspec.lock index e2098bb1..602d2265 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -210,7 +210,7 @@ packages: source: hosted version: "3.1.3" dbus: - dependency: transitive + dependency: "direct main" description: name: dbus sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" @@ -567,6 +567,15 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.2" + kwin: + dependency: "direct main" + description: + path: "." + ref: "39101ab0e2187cf8e56b6f4469dbe75b4cd485fb" + resolved-ref: "39101ab0e2187cf8e56b6f4469dbe75b4cd485fb" + url: "https://github.com/Merrit/kwin-dart.git" + source: git + version: "0.1.0" launch_at_startup: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index e8391916..61bf2b55 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: args: ^2.2.0 collection: ^1.16.0 + dbus: ^0.7.10 ffi: ^2.0.1 flutter: sdk: flutter @@ -32,6 +33,10 @@ dependencies: http: ^1.1.0 intl: any json_annotation: ^4.8.1 + kwin: + git: + url: https://github.com/Merrit/kwin-dart.git + ref: 39101ab0e2187cf8e56b6f4469dbe75b4cd485fb launch_at_startup: ^0.5.1 logger: ^2.0.1 package_info_plus: ^9.0.0 @@ -79,6 +84,7 @@ flutter: generate: true assets: - assets/icons/ + - assets/lib/linux/ - assets/lib/windows/ - packaging/linux/codes.merritt.Nyrna.desktop diff --git a/test/active_window/src/active_window_test.dart b/test/active_window/src/active_window_test.dart index 044c8bb6..c00edca5 100644 --- a/test/active_window/src/active_window_test.dart +++ b/test/active_window/src/active_window_test.dart @@ -28,7 +28,7 @@ const testProcess = Process( ); const testWindow = Window( - id: 130023427, + id: '130023427', process: testProcess, title: 'Untitled-2 - Visual Studio Code - Insiders', ); @@ -59,7 +59,7 @@ void main() { when(appWindow.hide()).thenAnswer((_) async => true); // NativePlatform - when(nativePlatform.activeWindow()).thenAnswer((_) async => testWindow); + when(nativePlatform.activeWindow).thenReturn(testWindow); when(nativePlatform.minimizeWindow(any)).thenAnswer((_) async => true); when(nativePlatform.restoreWindow(any)).thenAnswer((_) async => true); @@ -119,8 +119,8 @@ void main() { test('explorer.exe executable aborts on Win32', () async { debugDefaultTargetPlatformOverride = TargetPlatform.windows; - when(nativePlatform.activeWindow()).thenAnswer( - (_) async => testWindow.copyWith( + when(nativePlatform.activeWindow).thenReturn( + testWindow.copyWith( process: testProcess.copyWith(executable: 'explorer.exe'), ), ); @@ -143,13 +143,13 @@ void main() { final nyrnaWindow = testWindow.copyWith( process: testProcess.copyWith(executable: 'nyrna'), ); - when(nativePlatform.activeWindow()).thenAnswerInOrder([ - Future.value(nyrnaWindow), - Future.value(testWindow), + when(nativePlatform.activeWindow).thenAnswerInOrder([ + nyrnaWindow, + testWindow, ]); final successful = await activeWindow.toggle(); expect(successful, true); - verify(nativePlatform.activeWindow()).called(2); + verify(nativePlatform.checkActiveWindow()).called(2); verify(appWindow.hide()).called(1); }, ); @@ -160,13 +160,13 @@ void main() { final nyrnaWindow = testWindow.copyWith( process: testProcess.copyWith(executable: 'nyrna.exe'), ); - when(nativePlatform.activeWindow()).thenAnswerInOrder([ - Future.value(nyrnaWindow), - Future.value(testWindow), + when(nativePlatform.activeWindow).thenAnswerInOrder([ + nyrnaWindow, + testWindow, ]); final successful = await activeWindow.toggle(); expect(successful, true); - verify(nativePlatform.activeWindow()).called(2); + verify(nativePlatform.checkActiveWindow()).called(2); verify(appWindow.hide()).called(1); }, ); diff --git a/test/apps_list/cubit/apps_list_cubit_test.dart b/test/apps_list/cubit/apps_list_cubit_test.dart index b5caf6b9..b6e9eecf 100644 --- a/test/apps_list/cubit/apps_list_cubit_test.dart +++ b/test/apps_list/cubit/apps_list_cubit_test.dart @@ -36,7 +36,7 @@ const msPaintProcess = Process( ); const msPaintWindow = Window( - id: 132334, + id: '132334', process: msPaintProcess, title: 'Untitled - Paint', ); @@ -47,7 +47,7 @@ Window get msPaintWindowState => .singleWhere((element) => element.id == msPaintWindow.id); const mpvWindow1 = Window( - id: 180355074, + id: '180355074', process: Process( executable: 'mpv', pid: 1355281, @@ -62,7 +62,7 @@ Window get mpvWindow1State => .singleWhere((element) => element.id == mpvWindow1.id); const mpvWindow2 = Window( - id: 197132290, + id: '197132290', process: Process( executable: 'mpv', pid: 1355477, @@ -214,8 +214,8 @@ void main() { test('windows are sorted', () async { when(nativePlatform.windows(showHidden: anyNamed('showHidden'))).thenAnswer( (_) async => [ - const Window( - id: 7363, + Window( + id: '7363', process: Process( executable: 'kate', pid: 836482, @@ -223,8 +223,8 @@ void main() { ), title: 'Kate', ), - const Window( - id: 29347, + Window( + id: '29347', process: Process( executable: 'evince', pid: 94847, @@ -232,8 +232,8 @@ void main() { ), title: 'Evince', ), - const Window( - id: 89374, + Window( + id: '89374', process: Process( executable: 'ark', pid: 9374623, diff --git a/test/apps_list/widgets/window_tile_test.dart b/test/apps_list/widgets/window_tile_test.dart index 750da8fa..f178224b 100644 --- a/test/apps_list/widgets/window_tile_test.dart +++ b/test/apps_list/widgets/window_tile_test.dart @@ -36,7 +36,7 @@ final mockStorageRepository = MockStorageRepository(); final mockSystemTrayManager = MockSystemTrayManager(); const defaultTestWindow = Window( - id: 548331, + id: '548331', process: Process( executable: 'firefox-bin', pid: 8749655, diff --git a/test/native_platform/src/linux/linux_test.dart b/test/native_platform/src/linux/linux_test.dart index 32ec3701..a90234dc 100644 --- a/test/native_platform/src/linux/linux_test.dart +++ b/test/native_platform/src/linux/linux_test.dart @@ -1,17 +1,36 @@ +import 'dart:async'; import 'dart:io'; +import 'package:kwin/kwin.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; import 'package:nyrna/logs/logs.dart'; import 'package:nyrna/native_platform/native_platform.dart'; +import 'package:nyrna/native_platform/src/linux/active_window/active_window_wayland.dart'; +import 'package:nyrna/native_platform/src/linux/dbus/nyrna_dbus.dart'; import 'package:nyrna/native_platform/src/linux/linux.dart'; import 'package:nyrna/native_platform/src/typedefs.dart'; import 'package:test/test.dart'; +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +import 'linux_test.mocks.dart'; + +var mockKWin = MockKWin(); +var mockNyrnaDbus = MockNyrnaDbus(); + late RunFunction mockRun; final stubSuccessfulProcessResult = ProcessResult(0, 0, '', ''); final stubFailureProcessResult = ProcessResult(0, 1, '', 'error'); void main() { + if (Platform.operatingSystem != 'linux') { + return; + } + setUpAll(() async { await LoggingManager.initialize(verbose: false); }); @@ -20,6 +39,9 @@ void main() { mockRun = (String executable, List args) async { return ProcessResult(1, 1, '', ''); }; + + reset(mockKWin); + reset(mockNyrnaDbus); }); group('Linux:', () { @@ -31,7 +53,7 @@ void main() { 1 * DG: 8948x2873 VP: 0,0 WA: 0,0 8948x2420 Workspace 2'''; return ProcessResult(982333, 0, wmctrlReturnValue, ''); }); - Linux linux = Linux(mockRun); + Linux linux = await Linux.initialize(mockRun); int desktop = await linux.currentDesktop(); expect(desktop, 1); @@ -41,13 +63,18 @@ void main() { 1 - DG: 8948x2873 VP: 0,0 WA: 0,0 8948x2420 Workspace 2'''; return ProcessResult(982333, 0, wmctrlReturnValue, ''); }); - linux = Linux(mockRun); + linux = await Linux.initialize(mockRun); desktop = await linux.currentDesktop(); expect(desktop, 0); }); }); - test('windows() returns appropriate list of Window objects', () async { + test('windows() returns appropriate list of Window objects on x11', () async { + if (Platform.environment['XDG_SESSION_TYPE'] != 'x11') { + return; + } + + // Mock the run function to return the expected wmctrl output for x11. mockRun = ((executable, args) async { const wmctrlReturnValue = ''' 0x0640003e 0 8062 shodan Muesli - Wikipedia — Mozilla Firefox @@ -77,11 +104,13 @@ void main() { return ProcessResult(982333, 0, wmctrlReturnValue, ''); }); - final linux = Linux(mockRun); + + final linux = await Linux.initialize(mockRun); final windows = await linux.windows(); + final expected = [ const Window( - id: 104857662, + id: '104857662', process: Process( executable: 'firefox-bin', pid: 8062, @@ -90,7 +119,7 @@ void main() { title: 'Muesli - Wikipedia — Mozilla Firefox', ), const Window( - id: 41943046, + id: '41943046', process: Process( executable: 'Telegram', pid: 140564, @@ -99,7 +128,7 @@ void main() { title: 'Telegram (4)', ), const Window( - id: 48234538, + id: '48234538', process: Process( executable: 'nautilus', pid: 157040, @@ -111,7 +140,216 @@ void main() { expect(windows, expected); }); + test('windows() returns appropriate list of Window objects on wayland', () async { + const kdeWaylandSession = SessionType( + displayProtocol: DisplayProtocol.wayland, + environment: DesktopEnvironment.kde, + ); + + const mockWindowJsonFromKdeWaylandScript = + '[{"caption":"Wayland to X Recording bridge — Xwayland Video Bridge","pid":4962,"internalId":"{008b967c-58df-4128-ab4b-008acec9c4c8}","onCurrentDesktop":true},{"caption":"Muesli - Wikipedia — Mozilla Firefox","pid":7284,"internalId":"{b0c70d8b-07ae-4ee6-9d49-78a930adef3e}","onCurrentDesktop":true},{"caption":"Home — Dolphin","pid":627076,"internalId":"{d36459ad-bb52-44da-906e-990a393a9c8b}","onCurrentDesktop":true},{"caption":"doctor.cpp - libkscreen - Visual Studio Code","pid":945351,"internalId":"{efe8603d-5049-4451-8957-ed2e8eec5e04}","onCurrentDesktop":true},{"caption":"","pid":4419,"internalId":"{c892b0e3-ff47-4f77-b83c-5eef80104601}","onCurrentDesktop":true},{"caption":"QDBusViewer","pid":417686,"internalId":"{962a58dc-ee64-4223-b450-c2ba7861fc89}","onCurrentDesktop":false}]'; + + when(mockNyrnaDbus.windowsJson).thenReturn(mockWindowJsonFromKdeWaylandScript); + when(mockNyrnaDbus.activeWindowUpdates).thenAnswer((_) => const Stream.empty()); + + mockRun = ((executable, args) async { + String returnValue = ''; + if (executable == 'readlink') { + switch (args.first) { + case '/proc/4962/exe': + returnValue = '/usr/bin/xwaylandvideobridge'; + case '/proc/7284/exe': + returnValue = '/usr/lib64/firefox/firefox'; + case '/proc/627076/exe': + returnValue = '/usr/bin/dolphin'; + case '/proc/945351/exe': + returnValue = '/usr/share/code/code'; + case '/proc/4419/exe': + returnValue = '/usr/bin/plasmashell'; + case '/proc/417686/exe': + returnValue = '/usr/bin/qdbusviewer'; + default: + returnValue = ''; + } + } + return ProcessResult(0, 0, returnValue, ''); + }); + + final linux = await Linux.initialize( + mockRun, + kwin: mockKWin, + nyrnaDbus: mockNyrnaDbus, + sessionOverride: kdeWaylandSession, + ); + final windows = await linux.windows(); + + final expected = [ + const Window( + id: '{b0c70d8b-07ae-4ee6-9d49-78a930adef3e}', + process: Process( + executable: 'firefox', + pid: 7284, + status: ProcessStatus.unknown, + ), + title: 'Muesli - Wikipedia — Mozilla Firefox', + ), + const Window( + id: '{d36459ad-bb52-44da-906e-990a393a9c8b}', + process: Process( + executable: 'dolphin', + pid: 627076, + status: ProcessStatus.unknown, + ), + title: 'Home — Dolphin', + ), + const Window( + id: '{efe8603d-5049-4451-8957-ed2e8eec5e04}', + process: Process( + executable: 'code', + pid: 945351, + status: ProcessStatus.unknown, + ), + title: 'doctor.cpp - libkscreen - Visual Studio Code', + ), + ]; + + expect(windows, expected); + }); + + group('ActiveWindowWayland:', () { + test('sets linux.activeWindow when a JSON update is received', () async { + final controller = StreamController.broadcast(); + when(mockNyrnaDbus.windowsJson).thenReturn('[]'); + when(mockNyrnaDbus.activeWindowUpdates).thenAnswer((_) => controller.stream); + + mockRun = (executable, args) async { + if (executable == 'readlink' && args.first == '/proc/12345/exe') { + return ProcessResult(0, 0, '/usr/bin/firefox\n', ''); + } + return ProcessResult(0, 0, '', ''); + }; + + const kdeWaylandSession = SessionType( + displayProtocol: DisplayProtocol.wayland, + environment: DesktopEnvironment.kde, + ); + + final linux = await Linux.initialize( + mockRun, + kwin: mockKWin, + nyrnaDbus: mockNyrnaDbus, + sessionOverride: kdeWaylandSession, + ); + + const windowJson = + '{"caption":"Muesli - Wikipedia — Mozilla Firefox",' + '"pid":12345,"internalId":"{b0c70d8b-07ae}"}'; + controller.add(windowJson); + + // Allow the async listener callback to complete. + await Future.delayed(Duration.zero); + + expect(linux.activeWindow, isNotNull); + expect(linux.activeWindow!.id, '{b0c70d8b-07ae}'); + expect( + linux.activeWindow!.title, + 'Muesli - Wikipedia — Mozilla Firefox', + ); + expect(linux.activeWindow!.process.executable, 'firefox'); + expect(linux.activeWindow!.process.pid, 12345); + + await controller.close(); + await ActiveWindowWayland.dispose(); + }); + }); + + group('minimize/restore on KDE Wayland:', () { + const kdeWaylandSession = SessionType( + displayProtocol: DisplayProtocol.wayland, + environment: DesktopEnvironment.kde, + ); + + setUp(() { + when(mockNyrnaDbus.windowsJson).thenReturn('[]'); + when(mockNyrnaDbus.activeWindowUpdates).thenAnswer((_) => const Stream.empty()); + }); + + Future buildLinux() => Linux.initialize( + mockRun, + kwin: mockKWin, + nyrnaDbus: mockNyrnaDbus, + sessionOverride: kdeWaylandSession, + ); + + test( + 'minimizeWindow() calls kwin.loadScript() with correct script content', + () async { + final scriptCalls = <({String name, String content})>[]; + + when(mockKWin.loadScript(any, any)).thenAnswer((invocation) async { + final path = invocation.positionalArguments[0] as String; + final name = invocation.positionalArguments[1] as String; + final file = File(path); + final content = file.existsSync() ? await file.readAsString() : ''; + scriptCalls.add((name: name, content: content)); + }); + + final linux = await buildLinux(); + + // Clear calls made during initialize() (script loading). + scriptCalls.clear(); + + const windowId = '{b0c70d8b-07ae-4ee6-9d49-78a930adef3e}'; + await linux.minimizeWindow(windowId); + + expect(scriptCalls, hasLength(1)); + expect(scriptCalls.first.name, 'nyrna_minimize'); + + final expectedContent = Linux.kwinMinimizeRestoreScript + .replaceAll('%windowId%', windowId) + .replaceAll('%minimize%', 'true'); + expect(scriptCalls.first.content, expectedContent); + }, + ); + + test( + 'restoreWindow() calls kwin.loadScript() with correct script content', + () async { + final scriptCalls = <({String name, String content})>[]; + + when(mockKWin.loadScript(any, any)).thenAnswer((invocation) async { + final path = invocation.positionalArguments[0] as String; + final name = invocation.positionalArguments[1] as String; + final file = File(path); + final content = file.existsSync() ? await file.readAsString() : ''; + scriptCalls.add((name: name, content: content)); + }); + + final linux = await buildLinux(); + + // Clear calls made during initialize() (script loading). + scriptCalls.clear(); + + const windowId = '{b0c70d8b-07ae-4ee6-9d49-78a930adef3e}'; + await linux.restoreWindow(windowId); + + expect(scriptCalls, hasLength(1)); + expect(scriptCalls.first.name, 'nyrna_restore'); + + final expectedContent = Linux.kwinMinimizeRestoreScript + .replaceAll('%windowId%', windowId) + .replaceAll('%minimize%', 'false'); + expect(scriptCalls.first.content, expectedContent); + }, + ); + }); + group('checkDependencies:', () { + const x11Session = SessionType( + displayProtocol: DisplayProtocol.x11, + environment: DesktopEnvironment.kde, + ); + test('finds dependencies when present', () async { mockRun = ((executable, args) async { return (args[1].contains('wmctrl') || args[1].contains('xdotool')) @@ -119,7 +357,7 @@ void main() { : stubFailureProcessResult; }); - final linux = Linux(mockRun); + final linux = await Linux.initialize(mockRun, sessionOverride: x11Session); final haveDependencies = await linux.checkDependencies(); expect(haveDependencies, true); }); @@ -131,7 +369,7 @@ void main() { : stubFailureProcessResult; }); - final linux = Linux(mockRun); + final linux = await Linux.initialize(mockRun, sessionOverride: x11Session); final haveDependencies = await linux.checkDependencies(); expect(haveDependencies, false); }); @@ -143,7 +381,7 @@ void main() { : stubFailureProcessResult; }); - final linux = Linux(mockRun); + final linux = await Linux.initialize(mockRun, sessionOverride: x11Session); final haveDependencies = await linux.checkDependencies(); expect(haveDependencies, false); }); diff --git a/test/native_platform/src/native_platform_test.dart b/test/native_platform/src/native_platform_test.dart index f8f6fe80..b6d9641a 100644 --- a/test/native_platform/src/native_platform_test.dart +++ b/test/native_platform/src/native_platform_test.dart @@ -1,12 +1,14 @@ import 'dart:io' as io; +import 'package:nyrna/logs/logging_manager.dart'; import 'package:nyrna/native_platform/native_platform.dart'; import 'package:test/test.dart'; import 'skip_github.dart'; -void main() { - final platform = NativePlatform(); +Future main() async { + await LoggingManager.initialize(verbose: false); + final platform = await NativePlatform.initialize(); group('NativePlatform:', () { if (runningInCI) return; @@ -24,8 +26,10 @@ void main() { return; } - final activeWindow = await platform.activeWindow(); - expect(activeWindow.id, isPositive); + await platform.checkActiveWindow(); + final activeWindow = platform.activeWindow; + expect(activeWindow, isA()); + expect(activeWindow!.id, isPositive); expect(activeWindow.process.pid, isPositive); }); }); diff --git a/test/native_platform/src/window_test.dart b/test/native_platform/src/window_test.dart index c02dd685..c189f6e6 100644 --- a/test/native_platform/src/window_test.dart +++ b/test/native_platform/src/window_test.dart @@ -2,7 +2,7 @@ import 'package:nyrna/native_platform/native_platform.dart'; import 'package:test/test.dart'; void main() { - const fakeId = 8172363; + const fakeId = '8172363'; const fakeExecutable = 'firefox'; const fakePid = 1723128; const fakeProcess = Process(