From a0f20264c1c6b5805746af43d8c485101c540ad1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 17 Oct 2026 12:57:06 +0300 Subject: [PATCH] feat(macos): open files Finder hands over (Open With, double-click) at launch and while running; build the macOS app on PRs that touch it (#1589) --- .github/workflows/macos-build.yml | 39 ++++++++++ lib/core/platform/file_launch_service.dart | 6 ++ .../platform/macos_file_open_channel.dart | 48 ++++++++++++ lib/features/main_screen/main_screen.dart | 8 ++ lib/main.dart | 2 + macos/Runner/AppDelegate.swift | 77 +++++++++++++++++++ macos/Runner/MainFlutterWindow.swift | 3 + .../macos_file_open_channel_test.dart | 43 +++++++++++ 8 files changed, 226 insertions(+) create mode 100644 .github/workflows/macos-build.yml create mode 100644 lib/core/platform/macos_file_open_channel.dart create mode 100644 test/core/platform/macos_file_open_channel_test.dart diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml new file mode 100644 index 00000000..e183ec86 --- /dev/null +++ b/.github/workflows/macos-build.yml @@ -0,0 +1,39 @@ +name: macOS build + +# The PR checks run on Linux, so Swift in macos/Runner was first compiled by +# the release workflow on a tag. This builds the macOS app on pull requests +# that touch the native side (#1589). +on: + pull_request: + branches: [ main, dev ] + paths: + - 'macos/**' + - 'pubspec.yaml' + - 'pubspec.lock' + - '.github/workflows/macos-build.yml' + workflow_dispatch: + +concurrency: + group: macos-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build macOS (release, unsigned) + runs-on: macos-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.1' + channel: 'stable' + cache: true + + - name: Get dependencies + run: flutter pub get + + - name: Build macOS (release) + run: flutter build macos --release diff --git a/lib/core/platform/file_launch_service.dart b/lib/core/platform/file_launch_service.dart index 22023644..4bdc98d7 100644 --- a/lib/core/platform/file_launch_service.dart +++ b/lib/core/platform/file_launch_service.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show ValueNotifier; import 'package:path/path.dart' as p; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -121,9 +122,14 @@ class FileLaunchService { } } + /// Bumped by [addPendingTarget], so a running window can open files that + /// arrive after it started (Finder "Open With" on macOS, #1589). + final ValueNotifier pendingRevision = ValueNotifier(0); + /// Adds a target to the pending launch list (e.g. for testing or runtime open events). void addPendingTarget(FileLaunchTarget target) { _pendingTargets.add(target); + pendingRevision.value++; } /// Consumes and returns the first pending launch target, or `null` if none. diff --git a/lib/core/platform/macos_file_open_channel.dart b/lib/core/platform/macos_file_open_channel.dart new file mode 100644 index 00000000..9f3b5cb4 --- /dev/null +++ b/lib/core/platform/macos_file_open_channel.dart @@ -0,0 +1,48 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart' show debugPrint, visibleForTesting; +import 'package:flutter/services.dart'; +import 'package:querya_desktop/core/platform/file_launch_service.dart'; + +/// Files Finder hands to the app: "Open With" and a double-click (#1589). +/// +/// macOS delivers them as an Open Documents Apple Event, not in `argv`, so +/// [FileLaunchService.processLaunchArguments] never saw them. `AppDelegate` +/// keeps the ones that arrive before Dart asks ([takeLaunchFiles]) and then +/// sends each later batch over this channel. +abstract final class MacOsFileOpenChannel { + static const channel = MethodChannel('querya/file_open'); + + /// Starts listening and takes the files that arrived during launch. A + /// no-op off macOS. + static Future install() async { + if (!Platform.isMacOS) return; + channel.setMethodCallHandler((call) async { + if (call.method == 'openFiles') queuePaths(call.arguments); + return null; + }); + try { + queuePaths(await channel.invokeListMethod('takePendingFiles')); + } on MissingPluginException { + // A runner without the channel (older build): only argv works. + } on PlatformException catch (e) { + debugPrint('MacOsFileOpenChannel: $e'); + } + } + + /// Adds the paths in [arguments] (a list of strings) that the app can open + /// as pending launch targets; returns how many were added. + @visibleForTesting + static int queuePaths(Object? arguments) { + if (arguments is! List) return 0; + var added = 0; + for (final path in arguments) { + if (path is! String) continue; + final target = FileLaunchService.parseArgument(path); + if (target == null) continue; + FileLaunchService.instance.addPendingTarget(target); + added++; + } + return added; + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index ab203462..8ea1307c 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -74,6 +74,9 @@ class _MainScreenState extends State { return showUnsandboxedDriverConsentDialog(context, details); }; + // Files Finder opens while the window is up (#1589). + FileLaunchService.instance.pendingRevision.addListener(_onFilesQueued); + WidgetsBinding.instance.addPostFrameCallback((_) async { final completed = await AppSettings.instance.getHasCompletedWelcomeTour(); if (!completed && mounted) { @@ -90,8 +93,13 @@ class _MainScreenState extends State { }); } + void _onFilesQueued() { + if (mounted) unawaited(_handlePendingFileLaunch()); + } + @override void dispose() { + FileLaunchService.instance.pendingRevision.removeListener(_onFilesQueued); HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent); UnsandboxedLaunchConsentGate.instance.handler = null; _safeModeRelockTimer?.cancel(); diff --git a/lib/main.dart b/lib/main.dart index 7cc1489c..3268a7d7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'core/mcp/mcp_server_controller.dart'; import 'core/motion/display_refresh_service.dart'; import 'core/motion/querya_motion_controller.dart'; import 'core/platform/file_launch_service.dart'; +import 'core/platform/macos_file_open_channel.dart'; import 'core/security/ssl_certificate_support.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; @@ -26,6 +27,7 @@ void main([List args = const []]) async { WidgetsFlutterBinding.ensureInitialized(); installAppWiring(); FileLaunchService.instance.processLaunchArguments(args); + await MacOsFileOpenChannel.install(); FlutterError.onError = (details) { FlutterError.presentError(details); diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index b3c17614..0a671e1e 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -3,6 +3,26 @@ import FlutterMacOS @main class AppDelegate: FlutterAppDelegate { + /// Files Finder handed over ("Open With", double-click) before Dart asked + /// for them, and the channel to Dart once the window has an engine (#1589). + private var pendingPaths: [String] = [] + private var fileOpenChannel: FlutterMethodChannel? + private var dartTookPending = false + + override init() { + super.init() + // macOS sends files as an Open Documents Apple Event, not in argv. The + // handler is installed when launching starts, which replaces AppKit's + // default one, so it does not depend on the delegate methods of + // FlutterAppDelegate. + NotificationCenter.default.addObserver( + self, + selector: #selector(installOpenDocumentsHandler(_:)), + name: NSApplication.willFinishLaunchingNotification, + object: nil + ) + } + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } @@ -10,4 +30,61 @@ class AppDelegate: FlutterAppDelegate { override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { return true } + + @objc private func installOpenDocumentsHandler(_ notification: Notification) { + NSAppleEventManager.shared().setEventHandler( + self, + andSelector: #selector(handleOpenDocuments(_:withReplyEvent:)), + forEventClass: AEEventClass(kCoreEventClass), + andEventID: AEEventID(kAEOpenDocuments) + ) + } + + @objc private func handleOpenDocuments( + _ event: NSAppleEventDescriptor, + withReplyEvent reply: NSAppleEventDescriptor + ) { + guard let list = event.paramDescriptor(forKeyword: AEKeyword(keyDirectObject)) else { + return + } + var paths: [String] = [] + if list.numberOfItems == 0 { + if let url = list.fileURLValue { paths.append(url.path) } + } else { + for index in 1...list.numberOfItems { + if let url = list.atIndex(index)?.fileURLValue { paths.append(url.path) } + } + } + deliver(paths) + } + + private func deliver(_ paths: [String]) { + guard !paths.isEmpty else { return } + if dartTookPending, let channel = fileOpenChannel { + channel.invokeMethod("openFiles", arguments: paths) + } else { + pendingPaths.append(contentsOf: paths) + } + } + + /// Called by the main window once its Flutter engine exists. + func attachFileOpenChannel(messenger: FlutterBinaryMessenger) { + let channel = FlutterMethodChannel(name: "querya/file_open", binaryMessenger: messenger) + channel.setMethodCallHandler { [weak self] call, result in + guard let self = self else { + result(nil) + return + } + if call.method == "takePendingFiles" { + // From now on files go straight to Dart. + let paths = self.pendingPaths + self.pendingPaths.removeAll() + self.dartTookPending = true + result(paths) + } else { + result(FlutterMethodNotImplemented) + } + } + fileOpenChannel = channel + } } diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index b8e380c0..3a66dfd1 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -14,6 +14,9 @@ class MainFlutterWindow: BitsdojoWindow { self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) + (NSApp.delegate as? AppDelegate)?.attachFileOpenChannel( + messenger: flutterViewController.engine.binaryMessenger + ) super.awakeFromNib() } diff --git a/test/core/platform/macos_file_open_channel_test.dart b/test/core/platform/macos_file_open_channel_test.dart new file mode 100644 index 00000000..dd27a2dd --- /dev/null +++ b/test/core/platform/macos_file_open_channel_test.dart @@ -0,0 +1,43 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/platform/file_launch_service.dart'; +import 'package:querya_desktop/core/platform/macos_file_open_channel.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('MacOsFileOpenChannel (#1589)', () { + final service = FileLaunchService.instance; + setUp(service.consumePendingTargets); + tearDown(service.consumePendingTargets); + + test('queues the paths the app can open and skips the rest', () { + final before = service.pendingRevision.value; + + final added = MacOsFileOpenChannel.queuePaths([ + '/Users/ann/reports/q1.sql', + '/Users/ann/data/shop.sqlite', + '/Users/ann/notes.txt', + 42, + ]); + + expect(added, 2); + expect(service.pendingTargets.map((t) => t.kind), + [FileLaunchKind.sql, FileLaunchKind.sqlite]); + expect(service.pendingRevision.value, before + 2); + }); + + test('anything that is not a list adds nothing', () { + expect(MacOsFileOpenChannel.queuePaths(null), 0); + expect(MacOsFileOpenChannel.queuePaths('/a.sql'), 0); + expect(service.pendingTargets, isEmpty); + }); + + test('install is a no-op off macOS', () async { + if (Platform.isMacOS) return; + await MacOsFileOpenChannel.install(); + expect(service.pendingTargets, isEmpty); + }); + }); +}