Repository: pyinstaller/pyinstaller

Issue:
[proposed area:bootloader platform:OS X] Mac OS X Application Does Not Receive Drag 'n Drop File Paths
A Mac OS X application built with PyInstaller 4.1 `--onedir` and `--windowed` is run as single process (cf., a forked child process). Consequently, when it is started by a drag and drop operation, it does not process `OpenDoc` Apple Events and `sys.argv` does not include the dropped path(s).

From `pyi_main.c:pyi_main ()`:
```
#if defined(_WIN32) || defined(__APPLE__)

    /* On Windows and Mac use single-process for --onedir mode. */
    if (!extractionpath && !pyi_launch_need_to_extract_binaries(archive_status)) {
        VS("LOADER: No need to extract files to run; setting extractionpath to homepath\n");
        extractionpath = homepath;
    }

#endif
    :
    :
    if (extractionpath) {
        VS("LOADER: Already in the child - running user's code.\n");
          :
          :
    }
    else {
          :
          :
        /* Run the 'child' process, then clean up. */

        VS("LOADER: Executing self as child\n");
          :
          :
    }
```
File path aruguments not received on macOS
## Description of the issue

The app built successfully, however, when I right-click on a file and choose this app to open it, no arguments are received in python script.

However, it works when I manually pass the arguments in Terminal by `xxx.app/Contents/MacOS/executable Arg1 Arg2`, maybe the `kAEOpenDocuments` event is not handled correctly?

### Context information (for bug reports)

* Output of `pyinstaller --version`: ```4.3```
* Version of Python: 3.9.5
* Platform: macOS 11.4
* How you installed Python: pyenv
* Did you also try this on another platform? Does it work there?
This is a macOS-specific issue.


### A minimal example program which shows the error
Built a test app using the script & spec file below, right-click on any file and choose `test.app` to open.

document_event_test.py
```
#!/usr/bin/env python3
#encoding:utf-8

import sys, os

base = os.path.dirname(sys.argv[0])

with open(os.path.join(base, "logs.txt"), "w+") as f:
    if len(sys.argv) > 1:
        f.write("arguments received:\n")
        for arg in sys.argv[1:]:
            f.write(f"{arg}, is file: {os.path.isfile(arg)}\n")
    else:
        f.write("no arguments received!\n")

```

spec file:
```
# -*- mode: python ; coding: utf-8 -*-

block_cipher = None

a = Analysis(['document_event_test.py'],
             pathex=[],
             binaries=[],
             datas=[],
             hiddenimports=[],
             hookspath=[],
             runtime_hooks=[],
             excludes=[],
             win_no_prefer_redirects=False,
             win_private_assemblies=False,
             cipher=block_cipher,
             noarchive=False)

pyz = PYZ(a.pure, a.zipped_data,
             cipher=block_cipher)

exe = EXE(pyz,
          a.scripts,
          [],
          exclude_binaries=True,
          name='test',
          debug=False,
          bootloader_ignore_signals=False,
          strip=False,
          upx=False,
          console=False, 
	 icon='')

coll = COLLECT(exe,
               a.binaries,
               a.zipfiles,
               a.datas,
               strip=False,
               upx=False,
               upx_exclude=[],
               name='test')

app = BUNDLE(coll,
            name='test.app',
            bundle_identifier="test",
            info_plist={
                'NSPrincipalClass': 'NSApplication',
                'NSAppleScriptEnabled': False,
                'CFBundleDocumentTypes': [{
                        'CFBundleTypeName': 'test_file_types',
                        'CFBundleTypeExtensions': ['txt', 'std', 'stdf', 'std*'],
                        'CFBundleTypeRole': 'Viewer'
                        }],
                },
            )
```

### Stacktrace / full error message
A log file is generated in the example above, but the content of the log is always `no arguments received!`.



Discussion:
@cculianu Don't suppose you're in the mood for another #5295 ?
Hi! :) 

Oh I see -- so this is a totally different execution path that ignores the odoc AppleEvent stuff? I'll try and make some time for it this week to investigate.. from 10,000ft view it sounds/looks like this shouldn't be hard .. I hope.

I hacked `pyi_utils.[ch]` and `pyi_main.c:py_main()` to invoke `process_apple_events ()` and then provisionally update `archive_status->arg[cv]`.  It works, but it's not pretty.  Thus, I'll leave it in your capable hands.
That's awesome, thanks @cculianu. It's your call if you think it's worth the brainpower to do it.

@mwfong-csl If you can at least show us a git diff of your changes that'll probably save us a lot of time.

Sure.  Here they are:
```
diff --git a/bootloader/src/pyi_main.c b/bootloader/src/pyi_main.c
index 003929f..159c243 100644
--- a/bootloader/src/pyi_main.c
+++ b/bootloader/src/pyi_main.c
@@ -144,26 +144,6 @@ pyi_main(int argc, char * argv[])
     if (extractionpath) {
         VS("LOADER: Already in the child - running user's code.\n");
 
-        #if defined(__APPLE__) && defined(WINDOWED)
-            process_apple_events (true);
-
-            if (argc_pyi /* > 0 */) {
-                char **_argv = (char **) calloc (argc + argc_pyi + 1, sizeof (char *));
-                int    _argc = 0;
-
-                for (char **_arg = argv; *_arg /* != (char *) NULL */; ++_arg)
-                    _argv[_argc++] = *_arg;
-
-                for (char **_arg = argv_pyi; *_arg /* != (char *) NULL */; ++_arg)
-                    _argv[_argc++] = *_arg;
-
-                archive_status->argc = _argc;
-                archive_status->argv = _argv;
-
-                VS("LOADER: Appending OpenDoc paths to argv.\n");
-            }
-        #endif
-
         /*  If binaries were extracted to temppath,
          *  we pass it through status variable
          */
diff --git a/bootloader/src/pyi_utils.c b/bootloader/src/pyi_utils.c
index 50c68e8..fda1a4a 100644
--- a/bootloader/src/pyi_utils.c
+++ b/bootloader/src/pyi_utils.c
@@ -90,13 +90,8 @@ typedef void (*sighandler_t)(int);
  *  (this is argv_emulation).  These variables must be of file global scope to be able to
  *  be accessed inside of the AppleEvents handlers.
  */
-#if 0
 static char **argv_pyi = NULL;
 static int argc_pyi = 0;
-#else
-char **argv_pyi = NULL;
-int argc_pyi = 0;
-#endif
 
 /*
  * Watch for OpenDocument AppleEvents and add the files passed in to the
@@ -106,10 +101,7 @@ int argc_pyi = 0;
  * on the App icon in the OS X dock.
  */
 #if defined(__APPLE__) && defined(WINDOWED)
-#if 0
-static
-#endif
-void process_apple_events(Boolean);
+static void process_apple_events(Boolean);
 #endif
 
 
@@ -1459,10 +1451,7 @@ static OSStatus evt_handler_proc(EventHandlerCallRef href, EventRef eref, void *
 }
 
 /* Apple event message pump */
-#if 0
-static
-#endif
-void process_apple_events(Boolean short_timeout)
+static void process_apple_events(Boolean short_timeout)
 {
     static EventHandlerUPP handler;
     static AEEventHandlerUPP handler_ae;
diff --git a/bootloader/src/pyi_utils.h b/bootloader/src/pyi_utils.h
index 61856bf..88f85a4 100644
--- a/bootloader/src/pyi_utils.h
+++ b/bootloader/src/pyi_utils.h
@@ -52,14 +52,4 @@ int pyi_utils_create_child(const char *thisfile, const ARCHIVE_STATUS *status,
                            const int argc, char *const argv[]);
 int pyi_utils_set_environment(const ARCHIVE_STATUS *status);
 
-#if defined(__APPLE__) && defined(WINDOWED)
-extern char **argv_pyi;
-extern int argc_pyi;
-
-#include <Carbon/Carbon.h>  /* AppleEventsT */
-
-void process_apple_events(Boolean short_timeout);
-
-#endif
-
 #endif  /* HEADER_PY_UTILS_H */
```
Oh I see what's going on. It's swallowing up all the events! In the case where there is no subordinate subprocess (no child process), this bootloader is eating up the events so they never make it to the `exec`'d process that replaces the bootloader, perhaps?

I didn't realize this mode of operation is possible in PyInstaller (while I know C quite well and the Apple API reasonably well -- I was not super familiar with all of PyInstaller's inner workings).  Had I known that such a mode of operation is possible I probably would have handled this in the last set of changes I submitted to PyInstaller.

Ok so still from the 2.,000ft view I have now this sounds quite straightforward -- I'll make time this week (maybe even today) to play around with this and see how `--onedir` + `--windowed`   differs and hopefully remove the event-swallowing in that particular code path, so that it works as expected in all possible usages.

Thanks for the clarification, @mwfong-csl 
HI -- update: I haven't had time to look at this yet. Either this weekend or early next week. Soon though!
Re-opening this issue as support for onedir events processing / argv emulation has been reverted for now due to unforeseen side effects with `tkinter`-based applications.
Sounds like #5436, which also has a proof-of-concept patch that collects Apple events in `onedir` mode into `argv` before starting the python interpreter.
> Sounds like #5436, which also has a proof-of-concept patch that collects Apple events in `onedir` mode into `argv` before starting the python interpreter.

Thanks, I'll take a look into that patch. :)
Re-opening this issue as support for onedir events processing / argv emulation has been reverted for now due to unforeseen side effects with `tkinter`-based applications.

Resolving patch:
diff --git a/PyInstaller/building/api.py b/PyInstaller/building/api.py
--- a/PyInstaller/building/api.py
+++ b/PyInstaller/building/api.py
@@ -338,6 +338,9 @@ def __init__(self, *args, **kwargs):
                 Windows only. Setting to True creates a Manifest with will request elevation upon application start.
             uac_uiaccess
                 Windows only. Setting to True allows an elevated application to work with Remote Desktop.
+            argv_emulation
+                macOS only. Enables argv emulation in macOS .app bundles (i.e., windowed bootloader). If enabled, the
+                initial open document/URL Apple Events are intercepted by bootloader and converted into sys.argv.
             target_arch
                 macOS only. Used to explicitly specify the target architecture; either single-arch ('x86_64' or 'arm64')
                 or 'universal2'. Used in checks that the collected binaries contain the requires arch slice(s) and/or
@@ -374,6 +377,9 @@ def __init__(self, *args, **kwargs):
         self.uac_admin = kwargs.get('uac_admin', False)
         self.uac_uiaccess = kwargs.get('uac_uiaccess', False)
 
+        # macOS argv emulation
+        self.argv_emulation = kwargs.get('argv_emulation', False)
+
         # Target architecture (macOS only)
         self.target_arch = kwargs.get('target_arch', None)
         if is_darwin:
@@ -445,6 +451,10 @@ def __init__(self, *args, **kwargs):
             # no value; presence means "true"
             self.toc.append(("pyi-disable-windowed-traceback", "", "OPTION"))
 
+        if self.argv_emulation:
+            # no value; presence means "true"
+            self.toc.append(("pyi-macos-argv-emulation", "", "OPTION"))
+
         if is_win:
             if not self.icon:
                 # --icon not specified; use default from bootloader folder
@@ -501,6 +511,7 @@ def __init__(self, *args, **kwargs):
         ('uac_uiaccess', _check_guts_eq),
         ('manifest', _check_guts_eq),
         ('append_pkg', _check_guts_eq),
+        ('argv_emulation', _check_guts_eq),
         ('target_arch', _check_guts_eq),
         ('codesign_identity', _check_guts_eq),
         ('entitlements_file', _check_guts_eq),
diff --git a/PyInstaller/building/makespec.py b/PyInstaller/building/makespec.py
--- a/PyInstaller/building/makespec.py
+++ b/PyInstaller/building/makespec.py
@@ -517,6 +517,15 @@ def __add_options(parser):
     )
 
     g = parser.add_argument_group('Mac OS specific options')
+    g.add_argument(
+        "--argv-emulation",
+        dest="argv_emulation",
+        action="store_true",
+        default=False,
+        help="Enable argv emulation for macOS app bundles. If enabled, the intial open document/URL event is processed "
+        "by the bootloader and the passed file paths or URLs are appended to sys.argv.",
+    )
+
     g.add_argument(
         '--osx-bundle-identifier',
         dest='bundle_identifier',
@@ -612,6 +621,7 @@ def main(
     target_arch=None,
     codesign_identity=None,
     entitlements_file=None,
+    argv_emulation=False,
     **_kwargs
 ):
     # If appname is not specified - use the basename of the main script as name.
@@ -748,6 +758,8 @@ def main(
         'icon': icon_file,
         # .app bundle identifier. Only OSX uses this item.
         'bundle_identifier': bundle_identifier,
+        # argv emulation (macOS only)
+        'argv_emulation': argv_emulation,
         # Target architecture (macOS only)
         'target_arch': target_arch,
         # Code signing identity (macOS only)
diff --git a/PyInstaller/building/templates.py b/PyInstaller/building/templates.py
--- a/PyInstaller/building/templates.py
+++ b/PyInstaller/building/templates.py
@@ -47,6 +47,7 @@
           runtime_tmpdir=%(runtime_tmpdir)r,
           console=%(console)s,
           disable_windowed_traceback=%(disable_windowed_traceback)s,
+          argv_emulation=%(argv_emulation)r,
           target_arch=%(target_arch)r,
           codesign_identity=%(codesign_identity)r,
           entitlements_file=%(entitlements_file)r %(exe_options)s)
@@ -83,6 +84,7 @@
           upx=%(upx)s,
           console=%(console)s,
           disable_windowed_traceback=%(disable_windowed_traceback)s,
+          argv_emulation=%(argv_emulation)r,
           target_arch=%(target_arch)r,
           codesign_identity=%(codesign_identity)r,
           entitlements_file=%(entitlements_file)r %(exe_options)s)


Test patch:
diff --git a/tests/functional/scripts/pyi_log_args.py b/tests/functional/scripts/pyi_log_args.py
deleted file mode 100644
--- a/tests/functional/scripts/pyi_log_args.py
+++ /dev/null
@@ -1,24 +0,0 @@
-#-----------------------------------------------------------------------------
-# Copyright (c) 2019-2021, PyInstaller Development Team.
-#
-# Distributed under the terms of the GNU General Public License (version 2
-# or later) with exception for distributing the bootloader.
-#
-# The full license is in the file COPYING.txt, distributed with this software.
-#
-# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
-#-----------------------------------------------------------------------------
-
-import os
-import sys
-
-if len(sys.argv) > 1:
-    basedir = os.path.dirname(sys.executable)
-    # if script is inside .app package
-    if os.path.basename(basedir) == 'MacOS':
-        basedir = os.path.abspath(os.path.join(basedir, os.pardir, os.pardir, os.pardir))
-
-    logfile = os.path.join(basedir, 'args.log')
-    with open(logfile, 'w') as file:
-        for arg in sys.argv[1:]:
-            file.write(arg)
diff --git a/tests/functional/scripts/pyi_osx_aevent_logger_carbon.py b/tests/functional/scripts/pyi_osx_aevent_logger_carbon.py
new file mode 100644
--- /dev/null
+++ b/tests/functional/scripts/pyi_osx_aevent_logger_carbon.py
@@ -0,0 +1,334 @@
+#-----------------------------------------------------------------------------
+# Copyright (c) 2021, PyInstaller Development Team.
+#
+# Distributed under the terms of the GNU General Public License (version 2
+# or later) with exception for distributing the bootloader.
+#
+# The full license is in the file COPYING.txt, distributed with this software.
+#
+# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
+#-----------------------------------------------------------------------------
+
+# This is a CLI-based AppleEvent logger application, intended for testing argv emulation and AppleEvent forwarding in
+# PyInstaller-generated macOS .app bundles.
+
+import os
+import sys
+import time
+import json
+import ctypes
+import struct
+
+
+# The ctypes-based bindings for Carbon API are taken from py2app's argv_emulation.py
+class AEDesc(ctypes.Structure):
+    _fields_ = [
+        ("descKey", ctypes.c_int),
+        ("descContent", ctypes.c_void_p),
+    ]
+
+
+class EventTypeSpec(ctypes.Structure):
+    _fields_ = [
+        ("eventClass", ctypes.c_int),
+        ("eventKind", ctypes.c_uint),
+    ]
+
+
+def _ctypes_setup():
+    carbon = ctypes.CDLL("/System/Library/Carbon.framework/Carbon")
+
+    ae_callback = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p)
+    carbon.AEInstallEventHandler.argtypes = [
+        ctypes.c_int,
+        ctypes.c_int,
+        ae_callback,
+        ctypes.c_void_p,
+        ctypes.c_char,
+    ]
+    carbon.AERemoveEventHandler.argtypes = [
+        ctypes.c_int,
+        ctypes.c_int,
+        ae_callback,
+        ctypes.c_char,
+    ]
+
+    carbon.AEProcessEvent.restype = ctypes.c_int
+    carbon.AEProcessEvent.argtypes = [ctypes.c_void_p]
+
+    carbon.ReceiveNextEvent.restype = ctypes.c_int
+    carbon.ReceiveNextEvent.argtypes = [
+        ctypes.c_long,
+        ctypes.POINTER(EventTypeSpec),
+        ctypes.c_double,
+        ctypes.c_char,
+        ctypes.POINTER(ctypes.c_void_p),
+    ]
+
+    carbon.AEGetParamDesc.restype = ctypes.c_int
+    carbon.AEGetParamDesc.argtypes = [
+        ctypes.c_void_p,
+        ctypes.c_int,
+        ctypes.c_int,
+        ctypes.POINTER(AEDesc),
+    ]
+
+    carbon.AECountItems.restype = ctypes.c_int
+    carbon.AECountItems.argtypes = [
+        ctypes.POINTER(AEDesc),
+        ctypes.POINTER(ctypes.c_long),
+    ]
+
+    carbon.AEGetNthDesc.restype = ctypes.c_int
+    carbon.AEGetNthDesc.argtypes = [
+        ctypes.c_void_p,
+        ctypes.c_long,
+        ctypes.c_int,
+        ctypes.c_void_p,
+        ctypes.c_void_p,
+    ]
+
+    carbon.AEGetDescDataSize.restype = ctypes.c_int
+    carbon.AEGetDescDataSize.argtypes = [ctypes.POINTER(AEDesc)]
+
+    carbon.AEGetDescData.restype = ctypes.c_int
+    carbon.AEGetDescData.argtypes = [
+        ctypes.POINTER(AEDesc),
+        ctypes.c_void_p,
+        ctypes.c_int,
+    ]
+
+    carbon.FSRefMakePath.restype = ctypes.c_int
+    carbon.FSRefMakePath.argtypes = [
+        ctypes.c_void_p,
+        ctypes.c_void_p,
+        ctypes.c_uint,
+    ]
+
+    return carbon
+
+
+# Initialize Carbon bindings
+carbon = _ctypes_setup()
+
+kAEInternetSuite, = struct.unpack(">i", b"GURL")
+kAEGetURL, = struct.unpack(">i", b"GURL")
+kCoreEventClass, = struct.unpack(">i", b"aevt")
+kAEOpenApplication, = struct.unpack(">i", b"oapp")
+kAEReOpenApplication, = struct.unpack(">i", b"rapp")
+kAEActivate, = struct.unpack(">i", b"actv")
+kAEOpenDocuments, = struct.unpack(">i", b"odoc")
+keyDirectObject, = struct.unpack(">i", b"----")
+typeAEList, = struct.unpack(">i", b"list")
+typeChar, = struct.unpack(">i", b"TEXT")
+typeFSRef, = struct.unpack(">i", b"fsrf")
+FALSE = b"\0"
+TRUE = b"\1"
+eventLoopTimedOutErr = -9875
+
+kEventClassAppleEvent, = struct.unpack(">i", b"eppc")
+kEventAppleEvent = 1
+
+ae_callback = carbon.AEInstallEventHandler.argtypes[2]
+
+
+# Application
+class Application:
+    def __init__(self):
+        # Get runtime from command-line (first and only positional argument; filter our -psn_*** if it is present).
+        self.runtime = 15
+        filtered_args = [arg for arg in sys.argv[1:] if not arg.startswith('-psn')]
+        if filtered_args:
+            try:
+                self.runtime = float(filtered_args[0])
+            except Exception:
+                pass
+
+        # Track activations
+        self.activation_count = 0
+
+        # Open events log
+        self.logfile = open(self._get_logfile_path(), 'w')
+
+        # Event handlers map
+        self.ae_handlers = {
+            'oapp': self.open_app_handler,
+            'odoc': self.open_document_handler,
+            'GURL': self.open_url_handler,
+            'rapp': self.reopen_app_handler,
+            'actv': self.activate_app_handler,
+        }
+
+    def _get_logfile_path(self):
+        # Open log file
+        if getattr(sys, 'frozen', False):
+            basedir = os.path.dirname(sys.executable)
+            # Handle .app bundle
+            if os.path.basename(basedir) == 'MacOS':
+                basedir = os.path.abspath(os.path.join(basedir, os.pardir, os.pardir, os.pardir))
+        else:
+            basedir = os.path.dirname(__file__)
+
+        return os.path.join(basedir, 'events.log')
+
+    def log_error(self, message):
+        self.logfile.write(f"ERROR {message}\n")
+        self.logfile.flush()
+
+    def log_event(self, event_id, event_data={}):
+        self.logfile.write(f"{event_id} {json.dumps(event_data)}\n")
+        self.logfile.flush()
+
+    def main(self):
+        # Log application start.
+        self.log_event("started", {'args': sys.argv[1:]})
+
+        # Configure AppleEvent handlers.
+        @ae_callback
+        def _ae_handler(message, reply, refcon):
+            event_id = struct.pack(">i", refcon).decode('utf8')
+            print("Event handler called with event ID: %s" % (event_id,))
+            try:
+                handler = self.ae_handlers.get(event_id, None)
+                assert handler, "No handler available!"
+                event_data = handler(message, reply, refcon)
+                self.log_event(f"ae {event_id}", event_data)
+            except Exception as e:
+                print("Failed to handle event %s: %s!" % (event_id, e))
+                self.log_error(f"Failed to handle event '{event_id}': {e}")
+
+            return 0
+
+        carbon.AEInstallEventHandler(kCoreEventClass, kAEOpenApplication, _ae_handler, kAEOpenApplication, FALSE)
+        carbon.AEInstallEventHandler(kCoreEventClass, kAEOpenDocuments, _ae_handler, kAEOpenDocuments, FALSE)
+        carbon.AEInstallEventHandler(kAEInternetSuite, kAEGetURL, _ae_handler, kAEGetURL, FALSE)
+        carbon.AEInstallEventHandler(kCoreEventClass, kAEReOpenApplication, _ae_handler, kAEReOpenApplication, FALSE)
+        carbon.AEInstallEventHandler(kCoreEventClass, kAEActivate, _ae_handler, kAEActivate, FALSE)
+
+        # Run the main loop and process events.
+        start = time.time()
+
+        eventType = EventTypeSpec()
+        eventType.eventClass = kEventClassAppleEvent
+        eventType.eventKind = kEventAppleEvent
+
+        while time.time() < start + self.runtime:
+            event = ctypes.c_void_p()
+            status = carbon.ReceiveNextEvent(
+                1,
+                ctypes.byref(eventType),
+                max(start + self.runtime - time.time(), 0),
+                TRUE,
+                ctypes.byref(event),
+            )
+
+            if status == eventLoopTimedOutErr:
+                break
+            elif status != 0:
+                self.log_error(f"Failed to fetch events: {status}!")
+                break
+
+            status = carbon.AEProcessEvent(event)
+            if status != 0:
+                self.log_error(f"Failed to process event: {status}!")
+                break
+
+        # Cleanup
+        carbon.AERemoveEventHandler(kCoreEventClass, kAEOpenApplication, _ae_handler, FALSE)
+        carbon.AERemoveEventHandler(kCoreEventClass, kAEOpenDocuments, _ae_handler, FALSE)
+        carbon.AERemoveEventHandler(kAEInternetSuite, kAEGetURL, _ae_handler, FALSE)
+        carbon.AERemoveEventHandler(kCoreEventClass, kAEReOpenApplication, _ae_handler, FALSE)
+        carbon.AERemoveEventHandler(kCoreEventClass, kAEActivate, _ae_handler, FALSE)
+
+        # Log application finish.
+        self.log_event("finished", {'activation_count': self.activation_count})
+
+        self.logfile.close()
+        self.logfile = None
+
+    # *** Event handlers ***
+    def open_app_handler(self, message, reply, refcon):
+        # Nothing to do here, return empty dict.
+        self.activation_count += 1
+        return {}
+
+    def reopen_app_handler(self, message, reply, refcon):
+        # Increment the counter, return empty dict.
+        self.activation_count += 1
+        return {}
+
+    def activate_app_handler(self, message, reply, refcon):
+        # Increment the counter, return empty dict.
+        self.activation_count += 1
+        return {}
+
+    def open_document_handler(self, message, reply, refcon):
+        # Get descriptor list.
+        listdesc = AEDesc()
+        status = carbon.AEGetParamDesc(message, keyDirectObject, typeAEList, ctypes.byref(listdesc))
+        assert status == 0, f'Could not retrieve descriptor list: {status}!'
+
+        # Count items.
+        item_count = ctypes.c_long()
+        status = carbon.AECountItems(ctypes.byref(listdesc), ctypes.byref(item_count))
+        assert status == 0, f'Could not count number of items in descriptor list: {status}!'
+
+        # Collect data from all descriptors.
+        desc = AEDesc()
+        paths = []
+        for i in range(item_count.value):
+            # Retrieve descriptor.
+            status = carbon.AEGetNthDesc(ctypes.byref(listdesc), i + 1, typeFSRef, 0, ctypes.byref(desc))
+            assert status == 0, f'Could not retrieve descriptor #{i}: {status}!'
+
+            # Get data.
+            sz = carbon.AEGetDescDataSize(ctypes.byref(desc))
+            buf = ctypes.create_string_buffer(sz)
+            status = carbon.AEGetDescData(ctypes.byref(desc), buf, sz)
+            assert status == 0, f'Could not retrieve data for descriptor #{i}: {status}!'
+
+            # Decode path.
+            fsref = buf
+            buf = ctypes.create_string_buffer(4096)
+            status = carbon.FSRefMakePath(ctypes.byref(fsref), buf, 4095)
+            assert status == 0, f'Could not convert data for descriptor #{i} to path: {status}!'
+
+            # Append to output list.
+            paths.append(buf.value.decode("utf-8"))
+
+        return paths
+
+    def open_url_handler(self, message, reply, refcon):
+        # Get descriptor list.
+        listdesc = AEDesc()
+        status = carbon.AEGetParamDesc(message, keyDirectObject, typeAEList, ctypes.byref(listdesc))
+        assert status == 0, f'Could not retrieve descriptor list: {status}!'
+
+        # Count items.
+        item_count = ctypes.c_long()
+        status = carbon.AECountItems(ctypes.byref(listdesc), ctypes.byref(item_count))
+        assert status == 0, f'Could not count number of items in descriptor list: {status}!'
+
+        # Collect data from all descriptors.
+        desc = AEDesc()
+        urls = []
+        for i in range(item_count.value):
+            # Retrieve descriptor.
+            status = carbon.AEGetNthDesc(ctypes.byref(listdesc), i + 1, typeChar, 0, ctypes.byref(desc))
+            assert status == 0, f'Could not retrieve descriptor #{i}: {status}!'
+
+            # Get data.
+            sz = carbon.AEGetDescDataSize(ctypes.byref(desc))
+            buf = ctypes.create_string_buffer(sz)
+            status = carbon.AEGetDescData(ctypes.byref(desc), buf, sz)
+            assert status == 0, f'Could not retrieve data for descriptor #{i}: {status}!'
+
+            # Append to output list.
+            urls.append(buf.value.decode("utf-8"))
+
+        return urls
+
+
+if __name__ == '__main__':
+    app = Application()
+    app.main()
diff --git a/tests/functional/scripts/pyi_pyqt5_log_events.py b/tests/functional/scripts/pyi_pyqt5_log_events.py
deleted file mode 100644
--- a/tests/functional/scripts/pyi_pyqt5_log_events.py
+++ /dev/null
@@ -1,87 +0,0 @@
-#-----------------------------------------------------------------------------
-# Copyright (c) 2019-2021, PyInstaller Development Team.
-#
-# Distributed under the terms of the GNU General Public License (version 2
-# or later) with exception for distributing the bootloader.
-#
-# The full license is in the file COPYING.txt, distributed with this software.
-#
-# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
-#-----------------------------------------------------------------------------
-
-import json
-import os
-import sys
-
-from PyQt5.QtCore import QObject, QEvent, QTimer
-from PyQt5.QtWidgets import QWidget, QApplication, qApp
-
-
-class EventHandler(QObject):
-    def __init__(self, logfile, parent=None):
-        super().__init__(parent=parent)
-        assert isinstance(logfile, str) and logfile
-        self.logfile = logfile
-        self.activate_count = 0
-
-    def eventFilter(self, obj, event):
-        """
-        This event filter just logs the URLs it receives as FileOpen events to self.logfile
-        """
-        try:
-            if event.type() == QEvent.FileOpen:
-                with open(self.logfile, 'a') as file:
-                    file.write("url {}\n".format(event.url().toString()))
-                    file.write("activate_count {}\n".format(self.activate_count))
-                qApp.quit()  # Tell app to quit after receiving this event
-                return True
-            elif event.type() == QEvent.ApplicationActivate:
-                self.activate_count += 1
-        except Exception as e:
-            print("Caught exception in eventFilter exception: " + repr(e), file=sys.stderr)
-        return super().eventFilter(obj, event)
-
-    def log_started(self):
-        try:
-            with open(self.logfile, 'wt') as file:
-                file.write("started {}\n".format(json.dumps({"argv": sys.argv})))
-        except Exception as e:
-            print(
-                "Caught exception while attempting to write/open",
-                self.logfile,
-                "exception: " + repr(e),
-                file=sys.stderr
-            )
-            qApp.quit()
-
-
-def main():
-    basedir = os.path.dirname(sys.executable)
-    # if script is inside .app package
-    if os.path.basename(basedir) == 'MacOS':
-        basedir = os.path.abspath(os.path.join(basedir, os.pardir, os.pardir, os.pardir))
-
-    logfile = os.path.join(basedir, 'events.log')
-
-    app = QApplication(list(sys.argv))  # Copy args to prevent qApp modifying
-    dummy = QWidget()
-    dummy.showMinimized()
-    app.setQuitOnLastWindowClosed(False)
-    eh = EventHandler(logfile=logfile)
-    app.installEventFilter(eh)
-    # Log that we did start so the calling app knows
-    QTimer.singleShot(0, eh.log_started)
-    timeout = 7000  # Default 7 seconds
-    try:
-        # Last arg is timeout (may be passed-in from test script)
-        timeout = int(1000 * float(sys.argv[-1]))
-    except (ValueError, IndexError):
-        # Arg was missing or bad arg, use default
-        pass
-    # Quit the app after timeout milliseconds if we never get the event
-    QTimer.singleShot(timeout, app.quit)
-    app.exec_()
-
-
-if __name__ == '__main__':
-    main()
diff --git a/tests/functional/specs/pyi_osx_aevent_handling_carbon.spec b/tests/functional/specs/pyi_osx_aevent_handling_carbon.spec
new file mode 100644
--- /dev/null
+++ b/tests/functional/specs/pyi_osx_aevent_handling_carbon.spec
@@ -0,0 +1,83 @@
+# -*- mode: python -*-
+#-----------------------------------------------------------------------------
+# Copyright (c) 2005-2021, PyInstaller Development Team.
+#
+# Distributed under the terms of the GNU General Public License (version 2
+# or later) with exception for distributing the bootloader.
+#
+# The full license is in the file COPYING.txt, distributed with this software.
+#
+# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
+#-----------------------------------------------------------------------------
+
+import os
+
+app_name = 'pyi_osx_aevent_handling_carbon'
+app_src = os.path.join(os.path.dirname(SPECPATH), 'scripts', 'pyi_osx_aevent_logger_carbon.py')
+custom_url_scheme = os.environ.get('PYI_CUSTOM_URL_SCHEME', 'pyi-test-app')
+custom_file_ext = os.environ.get('PYI_CUSTOM_FILE_EXT', 'pyi_test_ext')
+build_mode = os.environ.get('PYI_BUILD_MODE', 'onefile')
+argv_emu = bool(int(os.environ.get('PYI_ARGV_EMU', '0')))
+
+a = Analysis([app_src])
+pyz = PYZ(a.pure, a.zipped_data)
+
+if build_mode == 'onefile':
+    exe = EXE(
+        pyz,
+        a.scripts,
+        a.binaries,
+        a.zipfiles,
+        a.datas,
+        name=app_name,
+        debug=True,
+        bootloader_ignore_signals=False,
+        argv_emulation=argv_emu,
+        strip=False,
+        upx=False,
+        console=False,
+    )
+    bundle_arg = exe
+elif build_mode == 'onedir':
+    exe = EXE(
+        pyz,
+        a.scripts,
+        exclude_binaries=True,
+        name=app_name,
+        debug=True,
+        bootloader_ignore_signals=False,
+        argv_emulation=argv_emu,
+        strip=False,
+        upx=False,
+        console=False,
+    )
+    coll = COLLECT(
+        exe,
+        a.binaries,
+        a.zipfiles,
+        a.datas,
+        strip=False,
+        upx=False,
+        name=app_name,
+    )
+    bundle_arg = coll
+
+app = BUNDLE(
+    bundle_arg,
+    name=app_name + '.app',
+    # Register custom protocol handler and custom file extension
+    info_plist={
+        'CFBundleURLTypes': [{
+            'CFBundleURLName': 'PYITestApp' + custom_url_scheme,
+            'CFBundleTypeRole': 'Viewer',
+            'CFBundleURLSchemes': [custom_url_scheme],
+        }],
+        'CFBundleDocumentTypes': [{
+            'CFBundleTypeName': "PYITestApp_" + custom_file_ext,
+            'CFBundleTypeExtensions': [
+                custom_file_ext,
+            ],
+            'CFBundleTypeRole': "Viewer",
+        }],
+    },
+)
diff --git a/tests/functional/specs/pyi_osx_custom_protocol_handler.spec b/tests/functional/specs/pyi_osx_custom_protocol_handler.spec
deleted file mode 100644
--- a/tests/functional/specs/pyi_osx_custom_protocol_handler.spec
+++ /dev/null
@@ -1,63 +0,0 @@
-# -*- mode: python -*-
-#-----------------------------------------------------------------------------
-# Copyright (c) 2005-2021, PyInstaller Development Team.
-#
-# Distributed under the terms of the GNU General Public License (version 2
-# or later) with exception for distributing the bootloader.
-#
-# The full license is in the file COPYING.txt, distributed with this software.
-#
-# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
-#-----------------------------------------------------------------------------
-
-import os
-
-app_name = 'pyi_osx_custom_protocol_handler'
-custom_url_scheme = os.environ.get('PYI_CUSTOM_URL_SCHEME', 'pyi-test-app')
-build_mode = os.environ.get('PYI_BUILD_MODE', 'onefile')
-
-a = Analysis(['../scripts/pyi_log_args.py'])
-pyz = PYZ(a.pure, a.zipped_data)
-
-if build_mode == 'onefile':
-     exe = EXE(pyz,
-               a.scripts,
-               a.binaries,
-               a.zipfiles,
-               a.datas,
-               name=app_name,
-               debug=True,
-               bootloader_ignore_signals=False,
-               strip=False,
-               upx=False,
-               console=False )
-     bundle_arg = exe
-elif build_mode == 'onedir':
-     exe = EXE(pyz,
-               a.scripts,
-               exclude_binaries=True,
-               name=app_name,
-               debug=True,
-               bootloader_ignore_signals=False,
-               strip=False,
-               upx=False,
-               console=False )
-     coll = COLLECT(exe,
-                    a.binaries,
-                    a.zipfiles,
-                    a.datas,
-                    strip=False,
-                    upx=False,
-                    name=app_name)
-     bundle_arg = coll
-
-app = BUNDLE(bundle_arg,
-             name=app_name + '.app',
-             # Register custom protocol handler
-             info_plist={
-                'CFBundleURLTypes': [{
-                    'CFBundleURLName': 'PYITestApp',
-                    'CFBundleTypeRole': 'Viewer',
-                    'CFBundleURLSchemes': [custom_url_scheme]
-                }]
-             })
diff --git a/tests/functional/specs/pyi_osx_event_forwarding.spec b/tests/functional/specs/pyi_osx_event_forwarding.spec
deleted file mode 100644
--- a/tests/functional/specs/pyi_osx_event_forwarding.spec
+++ /dev/null
@@ -1,71 +0,0 @@
-# -*- mode: python -*-
-#-----------------------------------------------------------------------------
-# Copyright (c) 2005-2021, PyInstaller Development Team.
-#
-# Distributed under the terms of the GNU General Public License (version 2
-# or later) with exception for distributing the bootloader.
-#
-# The full license is in the file COPYING.txt, distributed with this software.
-#
-# SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
-#-----------------------------------------------------------------------------
-
-import os
-
-app_name = 'pyi_osx_event_forwarding'
-custom_url_scheme = os.environ.get('PYI_CUSTOM_URL_SCHEME', 'pyi-test-app')
-custom_file_ext = os.environ.get('PYI_CUSTOM_FILE_EXT', 'pyi_test_ext')
-build_mode = os.environ.get('PYI_BUILD_MODE', 'onefile')
-
-a = Analysis([os.path.join(os.path.dirname(SPECPATH), 'scripts/pyi_pyqt5_log_events.py')])
-pyz = PYZ(a.pure, a.zipped_data)
-
-if build_mode == 'onefile':
-     exe = EXE(pyz,
-               a.scripts,
-               a.binaries,
-               a.zipfiles,
-               a.datas,
-               name=app_name,
-               debug=True,
-               bootloader_ignore_signals=False,
-               strip=False,
-               upx=False,
-               console=False )
-     bundle_arg = exe
-elif build_mode == 'onedir':
-     exe = EXE(pyz,
-               a.scripts,
-               exclude_binaries=True,
-               name=app_name,
-               debug=True,
-               bootloader_ignore_signals=False,
-               strip=False,
-               upx=False,
-               console=False )
-     coll = COLLECT(exe,
-                    a.binaries,
-                    a.zipfiles,
-                    a.datas,
-                    strip=False,
-                    upx=False,
-                    name=app_name)
-     bundle_arg = coll
-
-app = BUNDLE(bundle_arg,
-             name=app_name + '.app',
-             # Register custom protocol handler and custom file extension
-             info_plist={
-                'CFBundleURLTypes': [{
-                    'CFBundleURLName': 'PYITestApp' + custom_url_scheme,
-                    'CFBundleTypeRole': 'Viewer',
-                    'CFBundleURLSchemes': [custom_url_scheme],
-                }],
-                'CFBundleDocumentTypes': [{
-                    'CFBundleTypeName': "PYITestApp_" + custom_file_ext,
-                    'CFBundleTypeExtensions': [
-                        custom_file_ext,
-                    ],
-                    'CFBundleTypeRole': "Viewer",
-                }],
-             })
diff --git a/tests/functional/test_apple_events.py b/tests/functional/test_apple_events.py
--- a/tests/functional/test_apple_events.py
+++ b/tests/functional/test_apple_events.py
@@ -9,7 +9,7 @@
 # SPDX-License-Identifier: (GPL-2.0-or-later WITH Bootloader-exception)
 #-----------------------------------------------------------------------------
 """
-OSX-specific test to check handling AppleEvents by bootloader.
+macOS-specific test to check handling of Apple Events in the bootloader.
 """
 
 import json
@@ -19,96 +19,74 @@
 
 import pytest
 
-from PyInstaller.utils.tests import importorskip
 
-
-@pytest.mark.darwin
-@pytest.mark.parametrize("mode", ['onefile'])
-def test_osx_custom_protocol_handler(tmpdir, pyi_builder_spec, monkeypatch, mode):
-    app_path = os.path.join(tmpdir, 'dist', 'pyi_osx_custom_protocol_handler.app')
-    logfile_path = os.path.join(tmpdir, 'dist', 'args.log')
-
-    # Generate new URL scheme to avoid collisions
-    custom_url_scheme = "pyi-test-%i" % time.time()
-    monkeypatch.setenv("PYI_CUSTOM_URL_SCHEME", custom_url_scheme)
-    monkeypatch.setenv("PYI_BUILD_MODE", mode)
-
-    pyi_builder_spec.test_spec('pyi_osx_custom_protocol_handler.spec')
-
-    # First run using 'open' registers custom protocol handler
-    subprocess.check_call(['open', app_path])
-    # 'open' starts program in a different process so we need to wait for it to finish
-    time.sleep(5)
-
-    # Call custom protocol handler
-    url = custom_url_scheme + "://url-args"
-    subprocess.check_call(['open', url])
-    # Wait for the program to finish
-    time.sleep(5)
-    assert os.path.exists(logfile_path), 'Missing args logfile'
-    with open(logfile_path, 'r') as fh:
-        log_lines = fh.readlines()
-    assert log_lines and log_lines[-1] == url, 'Invalid arg appended'
-
-
-@pytest.mark.darwin
-@importorskip('PyQt5')
-@pytest.mark.parametrize("mode", ['onefile'])
-def test_osx_event_forwarding(tmpdir, pyi_builder_spec, monkeypatch, mode):
-    app_path = os.path.join(tmpdir, 'dist', 'pyi_osx_event_forwarding.app')
-
-    logfile_path = os.path.join(tmpdir, 'dist', 'events.log')
-
-    # Generate unique URL scheme & file ext to avoid collisions
-    unique_key = int(time.time())
-    custom_url_scheme = "pyi-test-%i" % unique_key
-    custom_file_ext = 'pyi_test_%i' % unique_key
-    monkeypatch.setenv("PYI_CUSTOM_URL_SCHEME", custom_url_scheme)
-    monkeypatch.setenv("PYI_CUSTOM_FILE_EXT", custom_file_ext)
-    monkeypatch.setenv("PYI_BUILD_MODE", mode)
-
-    # test_script builds the app then implicitly runs the script, so we pass arg "0" to tell the built script to exit
-    # right away here.
-    pyi_builder_spec.test_spec('pyi_osx_event_forwarding.spec', app_args=["0"])
-
-    timeout = 60.0  # Give up after 60 seconds
-    polltime = 0.25  # Poll events.log every 250ms
-
-    def wait_for_started():
+# This test function is similar to the former test_osx_event_forwarding, but is designed to test onefile vs onedir
+# builds, with argv_emu on and off. It is also designed to be used with different Apple Event loggers based on
+# different toolkits: Carbon via ctypes, tkinter, PyQt5, provided the logger implements the common log file format.
+#
+# However, in practice, in turns out that each high-level toolkit handles events a bit differently, and offers different
+# level of support (in tkinter/Tk case even depending on the Tk version). Therefore, this test function is currently
+# used only with Carbon-based logger and does not (yet) implement toolkit-specific quirks.
+def _test_apple_events_handling(appname, tmpdir, pyi_builder_spec, monkeypatch, build_mode, argv_emu):
+    # Helper for determining application start/finish via logged events.
+    def wait_for_event(logfile, event, timeout=60, polltime=0.25):
+        """
+        Wait for the log file with 'started' or 'finished' entry to appear.
+        """
+        assert event in {'started', 'finished'}, f"Invalid event: {event}!"
         t0 = time.time()  # mark start time
-        # Poll logfile for app to be started (it writes "started" to the first log line)
+        # Poll logfile for desired event (the app writes 'started' at beginning and 'finished' at the end).
         while True:
             elapsed = time.time() - t0
             if elapsed > timeout:
-                return
+                return False
             if os.path.exists(logfile_path):
                 with open(logfile_path) as fh:
                     log_lines = fh.readlines()
                     if log_lines:
-                        first = log_lines[0]
-                        assert first.startswith('started '), "Unexpected line in log file"
-                        # Now, parse the logged args. e.g. 'started {"argv": ["Arg1, ...]}'
-                        dd = json.loads(first.split(" ", 1)[-1])
-                        assert 'argv' in dd, "First line missing argv"
-                        return dd['argv']  # it started ok, abort loop
-            else:
-                # Try again later
-                time.sleep(polltime)
-
-    # wait for the app started for us by test_spec to exit
-    assert wait_for_started(), "App did not start"
-
-    time.sleep(2)  # presumably app has exited after 2 seconds
-
-    # clean up the log file created by test_spec() running the app
+                        if event == 'started':
+                            first = log_lines[0]
+                            assert first.startswith('started '), "Unexpected line in log file!"
+                            return True
+                        elif log_lines[-1].startswith('finished '):
+                            return True
+            # Try again later
+            time.sleep(polltime)
+
+    # Generate unique URL scheme & file ext to avoid collisions.
+    unique_key = int(time.time())
+    custom_url_scheme = "pyi-test-%i" % unique_key
+    custom_file_ext = 'pyi_test_%i' % unique_key
+    monkeypatch.setenv("PYI_CUSTOM_URL_SCHEME", custom_url_scheme)
+    monkeypatch.setenv("PYI_CUSTOM_FILE_EXT", custom_file_ext)
+    monkeypatch.setenv("PYI_BUILD_MODE", build_mode)
+    monkeypatch.setenv("PYI_ARGV_EMU", str(int(argv_emu)))
+
+    app_path = os.path.join(tmpdir, 'dist', appname + '.app')
+    logfile_path = os.path.join(tmpdir, 'dist', 'events.log')
+
+    # test_spec() builds the app and automatically runs it. As we want to test custom protocol handler registration, we
+    # want this run to exit as soon as possible. Passing arg "0" would have it exit immediately, but with some logger
+    # applications (e.g., pure Carbon based), that prevents bundle's executable to be found on subsequent open attempts;
+    # presumably because event loop does not process any events. So use 5-seconds timeout instead.
+    pyi_builder_spec.test_spec(appname + '.spec', app_args=["5"])
+
+    # Wait for the app started by test_spec() to exit
+    assert wait_for_event(logfile_path, 'started'), 'Timeout while waiting for app to start (test_spec run)!'
+    assert wait_for_event(logfile_path, 'finished'), 'Timeout while waiting for app to finish (test_spec run)!'
+    time.sleep(5)  # wait for app to fully exit
+
+    # Clean up the log file created by test_spec() running the app
     os.remove(logfile_path)
 
-    # Run using 'open', passing a 0-timeout as an arg. macOS will auto-register the custom protocol handler and
-    # extension association. Then app will quit immediately due to the "0" arg.
-    subprocess.check_call(['open', app_path, '--args', "0"])
+    # Run using 'open', passing a 5-second timeout as an arg to exit as soon as possible (do not pass 0 to prevent
+    # skipping the event loop in the application). This will cause macOS to register the custom protocol handler and
+    # file extension association.
+    subprocess.check_call(['open', app_path, '--args', "5"])
 
-    assert wait_for_started(), 'App start timed out'
-    time.sleep(2)  # wait for app to exit
+    assert wait_for_event(logfile_path, 'started'), 'Timeout while waiting for app to start (registration run)!'
+    assert wait_for_event(logfile_path, 'finished'), 'Timeout while waiting for app to finish (registration run)!'
+    time.sleep(5)  # wait for app to fully exit
 
     # App exited immediately, clean-up
     os.remove(logfile_path)
@@ -128,74 +106,193 @@ def wait_for_started():
 
     # Open app again by "open"ing the associated files.
     #
-    # These are sent as Apple Events to the app immediately after it starts, which the bootloader translates back into
-    # file paths at startup, passing them as argv to the subordinate app.
+    # These are sent as Apple Events to the app immediately after it starts; if argv emulation is enabled, the
+    # bootloader processes these events and translates them into file paths, passing them as argv to the
+    # subordinate app.
     #
-    # The generator below produces odd numbered files as "file://" URLs, and even numbered are just file paths. They all
-    # should end up appended to sys.argv in the app as simple file paths.
-    subprocess.check_call(['open', *[('file://' if ii % 2 else '') + ff for ii, ff in enumerate(assoc_files)]])
+    # The generator below produces odd numbered files as "file://" URLs, and even numbered are just file paths. Under
+    # argv emulation, they should all end up appended to sys.argv in the app as simple file paths.
+    files_list = [('file://' if ii % 2 else '') + ff for ii, ff in enumerate(assoc_files)]
+    subprocess.check_call(['open', *files_list])
 
-    args = wait_for_started()
-    assert args is not None, 'App start timed out'
-    # Test that all the file paths were received in argv via pre-startup translation of file:// AppleEvent -> argv
-    # filesystem path.
-    assert assoc_files == args[1:], "An expected file path was not received by the app"
+    assert wait_for_event(logfile_path, 'started'), 'Timeout while waiting for app to start (test run)!'
 
     # At this point the app is running.
 
-    # This is a trick to make our app lose focus so that Qt forwards the "Activated" events properly to our event
-    # handler in pyi_pyqt5_log_events.py
+    # 2. Call open using the url associated with the app. This should forward the Apple URL event to the running app.
+    url_hello = custom_url_scheme + "://lowecase_required/hello_world/"
+    subprocess.check_call(['open', url_hello])
+    time.sleep(1.0)
+
+    # 3. Put application into background so that we can re-activate it. Taken from the former test_osx_event_forwarding
+    # that used PyQt5, in case we ever try to use this test function with PyQt5-based logger.
     subprocess.check_call(['osascript', "-e", 'tell application "System Events" to activate'])
     time.sleep(1.0)  # delay for above applescript
 
-    # The app is running now, in the background, and doesn't have focus
+    # The app is running now, in the background, and does not have focus.
 
-    # 2. Call open passing the app path again -- this should activate the already-running app and the activation_count
-    #    should be 2 after it exits.
+    # 4. Call open passing the app path again -- this should activate the already-running app.
     subprocess.check_call(['open', app_path])
     time.sleep(1.0)  # the activate event gets sent with a delay
 
-    # 3. Call open again using the url associated with the app. This should forward the Apple URL event to the
-    #    already-running app.
-    url = custom_url_scheme + "://lowecase_required/hello_world/"
-    # Test support for large URL data ~64KB. Note: We would have gone larger but 'open' itself seems to not consistently
-    # like data over a certain size.
-    url += 'x' * 64000  # Append 64 KB of data to URL to stress-test
-    subprocess.check_call(['open', url])
-    activation_count = None
-
-    def wait_for_event_in_logfile():
-        t0 = time.time()  # mark start time
-        # Wait for the program to finish -- poll for expected line to appear in events.log
-        while True:
-            assert os.path.exists(logfile_path), 'Missing events logfile'
-            with open(logfile_path, 'rt') as fh:
-                log_lines = fh.readlines()
-            if len(log_lines) >= 3:
-                url_line = log_lines[1]
-                activation_line = log_lines[2]
-                assert url_line.startswith("url ")
-                assert activation_line.startswith("activate_count ")
-                url_part = url_line.split(" ", 1)[-1]
-                assert url_part.strip().lower() == url.lower(), 'Logged url does not match expected'
-                activation_part = activation_line.split(" ", 1)[-1]
-                nonlocal activation_count
-                activation_count = int(activation_part.strip())
-                return True
-            else:
-                # Try again later
-                time.sleep(polltime)
-            elapsed = time.time() - t0
-            if elapsed > timeout:
-                return False
-
-    assert wait_for_event_in_logfile(), 'URL event did not appear in log before timeout'
-
-    assert activation_count == 2, "App did not receive rapp (re-Open app) event properly"
+    # 5. Call open with first four associated files.
+    files_list = [('file://' if ii % 2 else '') + ff for ii, ff in enumerate(assoc_files[:4])]
+    subprocess.check_call(['open', *files_list])
+    time.sleep(1.0)
+
+    # 6. Call open again using the url associated with the app. This should forward the Apple URL event to the
+    # already-running app.
+    url_goodbye = custom_url_scheme + "://lowecase_required/goodybe_galaxy/"
+    subprocess.check_call(['open', url_goodbye])
+    time.sleep(1.0)
+
+    # 7. Call open again using the url associated with the app, this time testing support for large URL data (~64kB).
+    url_large = custom_url_scheme + "://lowecase_required/large_data/"
+    # Note: We would have gone larger but 'open' itself seems to not consistently like data over a certain size.
+    url_large += 'x' * 64000  # Append 64 kB of data to URL to stress-test.
+    subprocess.check_call(['open', url_large])
+    time.sleep(1.0)
+
+    # 8. Call open with last four associated files again.
+    files_list = [('file://' if ii % 2 else '') + ff for ii, ff in enumerate(assoc_files[-4:])]
+    subprocess.check_call(['open', *files_list])
+    time.sleep(1.0)
+
+    # 9. Wait for application to finish.
+    assert wait_for_event(logfile_path, 'finished'), 'Timeout while waiting for app to finish (test run)!'
+    time.sleep(2)  # wait for app to fully exit
+
+    # *** Analyze the contents of the log file ***
+    with open(logfile_path, 'r') as fh:
+        log_lines = fh.readlines()
 
+    assert log_lines[0].startswith('started '), "Unexpected first line in log!"
+    assert log_lines[-1].startswith('finished '), "Unexpected last line in log!"
+
+    events = []
+    errors = []
+    unknown = []
+    for log_line in log_lines[1:-1]:
+        if log_line.startswith('ae '):
+            # (raw) Apple Event
+            _, event_id, event_data = log_line.split(" ", 2)
+            events.append((event_id, json.loads(event_data)))
+        elif log_line.startswith('ERROR '):
+            errors.append(log_line.split(" ", 1))
+        else:
+            unknown.append(log_line)
+
+    assert not errors, "Event log contains error(s)!"
+    assert not unknown, "Event log contains unknown line(s)!"
+
+    # First line: application start - read arguments.
+    data = json.loads(log_lines[0].split(" ", 1)[-1])
+    args = data['args']  # Validated below, with initial event.
+
+    # Last line: application finish - read activation count.
+    data = json.loads(log_lines[-1].split(" ", 1)[-1])
+    activation_count = data['activation_count']
+
+    # Event: intial event
+    #
+    # In onefile builds, we always receive oapp event, regardless of how application was opened (e.g., via odoc or
+    # GURL), because the actual opening event is received by the parent process, whereas child is independent and
+    # is always launched via "normal" open (oapp). Therefore, having the argv-emu enabled or not also has no effect on
+    # the initial oapp event in onefile mode.
+    #
+    # In onedir mode without argv emulation, we receive either oapp for normal open, or odoc/GURL if launched via open
+    # document/URL. The test run begins with an open-file request, so the initial event should be an odoc one.
+    #
+    # When argv-emu is enabled in onedir mode, the argv-emu event loop swallows that initial event, whatever it may have
+    # been. To make up for it and prevent issues with some toolkits (e.g., Tcl/Tk), it emits a fake oapp event.
+    initial_oapp = True  # expect initial oapp event
+    if build_mode == 'onedir' and not argv_emu:
+        initial_oapp = False  # initial event is odoc
+
+    event_idx = 0
+
+    if initial_oapp:
+        event, data = events[event_idx]
+        event_idx += 1
+        assert event == 'oapp'
+
+    # Validate arguments received at application start via argv.
+    if argv_emu:
+        # argv-emu; we expect whole list of files in arguments
+        assert args == assoc_files, "Arguments received via argv-emu do not match expected list!"
+    else:
+        # No argv-emu; args should be empty (in particular, the -psn_* arg should be filtered out).
+        assert args == [], "Application should receive no arguments when argv-emu is disabled!"
+
+        # The open-file request should be received as odoc request instead. Might be very first event, or might follow
+        # an oapp, depending on the build_mode.
+        event, data = events[event_idx]
+        event_idx += 1
+        assert event == 'odoc'
+        assert data == assoc_files
+
+    # Validate activation count at the time of finish. One contribution should be from the initial oapp event (if
+    # applicable), and one from re-activation when we sent application to background and then called it forward.
+    expected_activations = initial_oapp + 1
+    assert activation_count == expected_activations, "Application did not handle activation event(s) as expected!"
+
+    # The rest of events should be received normally, regardless of argv-emu. Either directly (onedir), or via the
+    # forwarding mechanism (onefile).
+
+    # Event: GURL with single URL (url_hello)
+    event, data = events[event_idx]
+    event_idx += 1
+    assert event == 'GURL'
+    assert data == [url_hello]
+
+    # Event: rapp (re-activation)
+    event, data = events[event_idx]
+    event_idx += 1
+    assert event == 'rapp'
+
+    # Event: odoc with first 4 files
+    event, data = events[event_idx]
+    event_idx += 1
+    assert event == 'odoc'
+    assert data == assoc_files[:4]
+
+    # Event: GURL with single URL (url_goodbye)
+    event, data = events[event_idx]
+    event_idx += 1
+    assert event == 'GURL'
+    assert data == [url_goodbye]
+
+    # Event: GURL with single large-data URL (url_large)
+    event, data = events[event_idx]
+    event_idx += 1
+    assert event == 'GURL'
+    assert data == [url_large]
+
+    # Event: odoc with last 4 files
+    event, data = events[event_idx]
+    event_idx += 1
+    assert event == 'odoc'
+    assert data == assoc_files[-4:]
+
+    # *** Cleanup ***
     # Delete all the temp files to be polite
     for ff in assoc_files:
         try:
             os.remove(ff)
         except OSError:
             pass
+
+
+@pytest.mark.darwin
+@pytest.mark.parametrize("build_mode", ['onefile', 'onedir'])
+@pytest.mark.parametrize("argv_emu", [True, False], ids=["emu", "noemu"])
+def test_apple_event_handling_carbon(tmpdir, pyi_builder_spec, monkeypatch, build_mode, argv_emu):
+    # Carbon-based event logger.
+    return _test_apple_events_handling(
+        'pyi_osx_aevent_handling_carbon',
+        tmpdir,
+        pyi_builder_spec,
+        monkeypatch,
+        build_mode,
+        argv_emu,
+    )