Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/macos-build.yml
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions lib/core/platform/file_launch_service.dart
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<int> pendingRevision = ValueNotifier<int>(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.
Expand Down
48 changes: 48 additions & 0 deletions lib/core/platform/macos_file_open_channel.dart
Original file line number Diff line number Diff line change
@@ -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<void> install() async {
if (!Platform.isMacOS) return;
channel.setMethodCallHandler((call) async {
if (call.method == 'openFiles') queuePaths(call.arguments);
return null;
});
try {
queuePaths(await channel.invokeListMethod<String>('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;
}
}
8 changes: 8 additions & 0 deletions lib/features/main_screen/main_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ class _MainScreenState extends State<MainScreen> {
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) {
Expand All @@ -90,8 +93,13 @@ class _MainScreenState extends State<MainScreen> {
});
}

void _onFilesQueued() {
if (mounted) unawaited(_handlePendingFileLaunch());
}

@override
void dispose() {
FileLaunchService.instance.pendingRevision.removeListener(_onFilesQueued);
HardwareKeyboard.instance.removeHandler(_handleGlobalKeyEvent);
UnsandboxedLaunchConsentGate.instance.handler = null;
_safeModeRelockTimer?.cancel();
Expand Down
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,7 @@ void main([List<String> args = const []]) async {
WidgetsFlutterBinding.ensureInitialized();
installAppWiring();
FileLaunchService.instance.processLaunchArguments(args);
await MacOsFileOpenChannel.install();

FlutterError.onError = (details) {
FlutterError.presentError(details);
Expand Down
77 changes: 77 additions & 0 deletions macos/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,88 @@ 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
}

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
}
}
3 changes: 3 additions & 0 deletions macos/Runner/MainFlutterWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
43 changes: 43 additions & 0 deletions test/core/platform/macos_file_open_channel_test.dart
Original file line number Diff line number Diff line change
@@ -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);
});
});
}
Loading