From 82fb2c7b0b46b0ed0e02aace5720cbe6f63dba53 Mon Sep 17 00:00:00 2001 From: Rhet Turnbull Date: Sat, 13 Jul 2024 16:15:37 -0500 Subject: [PATCH] Feature disclaim (#1614) * Call disclaim from cli entry point * Improved error message if access not granted * removed disclaim.py --- disclaim.py | 24 ---------------- osxphotos.spec | 4 +-- osxphotos/cli/cli.py | 6 ++++ osxphotos/cli/export.py | 27 ++++++++++-------- osxphotos/disclaim.py | 56 +++++++++++++++++++++++++++++++++++++ osxphotos/photokit_utils.py | 29 +++++++++++++++++++ osxphotos/utils.py | 8 ++++++ 7 files changed, 117 insertions(+), 37 deletions(-) delete mode 100644 disclaim.py create mode 100644 osxphotos/disclaim.py create mode 100644 osxphotos/photokit_utils.py diff --git a/disclaim.py b/disclaim.py deleted file mode 100644 index fc66b5b42..000000000 --- a/disclaim.py +++ /dev/null @@ -1,24 +0,0 @@ -def _pyi_rthook(): - import sys - if sys.platform != 'darwin': - return - - # Avoid redundant disclaims - import os - if os.environ.get('PYI_DISCLAIMED'): - return - os.environ['PYI_DISCLAIMED'] = '1' - - # The bootloader has cleared the _MEIPASS2 environment variable by the - # time we get here, which means re-launching the executable disclaimed - # will unpack the binary again. To avoid this we reset _MEIPASS2 again, - # so that our re-launch will pick up at second stage of the bootstrap. - os.environ['_MEIPASS2'] = sys._MEIPASS - - import ctypes - library_path = os.path.join(sys._MEIPASS, 'libdisclaim.dylib') - libdisclaim = ctypes.cdll.LoadLibrary(library_path) - libdisclaim.disclaim() - -_pyi_rthook() -del _pyi_rthook diff --git a/osxphotos.spec b/osxphotos.spec index ad948d897..5841f47bb 100644 --- a/osxphotos.spec +++ b/osxphotos.spec @@ -55,11 +55,11 @@ block_cipher = None a = Analysis( ["cli.py"], pathex=[pathex], - binaries=[("build/libdisclaim.dylib", ".")], + binaries=[("build/libdisclaim.dylib", "osxphotos")], datas=datas, hiddenimports=["pkg_resources.py2_warn"], hookspath=[], - runtime_hooks=["disclaim.py"], + # runtime_hooks=["disclaim.py"], excludes=[], win_no_prefer_redirects=False, win_private_assemblies=False, diff --git a/osxphotos/cli/cli.py b/osxphotos/cli/cli.py index 19483ba19..11ed59592 100644 --- a/osxphotos/cli/cli.py +++ b/osxphotos/cli/cli.py @@ -9,6 +9,7 @@ from osxphotos._constants import PROFILE_SORT_KEYS from osxphotos._version import __version__ +from osxphotos.disclaim import disclaim, pyinstaller, pyapp from osxphotos.platform import is_macos from .about import about @@ -106,6 +107,11 @@ def cli_main(ctx, profile, profile_sort, **kwargs): # the debug options are handled in cli/__init__.py # before this function is called ctx.obj = CLI_Obj(group=cli_main) + + if pyinstaller() or pyapp(): + # Running from executable, run disclaimer + disclaim() + if profile: click.echo("Profiling...") profile_sort = profile_sort or ["cumulative"] diff --git a/osxphotos/cli/export.py b/osxphotos/cli/export.py index 5a0fe0fec..bc90b86a8 100644 --- a/osxphotos/cli/export.py +++ b/osxphotos/cli/export.py @@ -64,6 +64,7 @@ from osxphotos.path_utils import is_valid_filepath, sanitize_filename, sanitize_filepath from osxphotos.photoexporter import PhotoExporter from osxphotos.photoinfo import PhotoInfoNone +from osxphotos.photokit_utils import wait_for_photokit_authorization from osxphotos.photoquery import load_uuid_from_file, query_options_from_kwargs from osxphotos.phototemplate import PhotoTemplate, RenderOptions from osxphotos.platform import get_macos_version, is_macos @@ -74,6 +75,7 @@ is_mounted_volume, is_photoslibrary_path, pluralize, + terminal, under_test, ) @@ -1684,17 +1686,20 @@ def export_cli( else: report_writer = ReportWriterNoOp() - # if use_photokit and not check_photokit_authorization(): - # click.echo( - # "Requesting access to use your Photos library. Click 'OK' on the dialog box to grant access." - # ) - # request_photokit_authorization() - # click.confirm("Have you granted access?") - # if not check_photokit_authorization(): - # click.echo( - # "Failed to get access to the Photos library which is needed with `--use-photokit`." - # ) - # return + if (use_photokit or use_photos_export) and not check_photokit_authorization(): + click.echo( + "Requesting access to use your Photos library. " + "Click 'Allow Access to All Photos' in the dialog box to grant access." + ) + if not wait_for_photokit_authorization(): + if term := terminal(): + term = f"terminal app ({term})" if term else "terminal app" + rich_click_echo( + f"[error]Error: could not get authorization to access Photos library\n" + f"Please ensure that your {term} is granted access in " + "'System Settings > Privacy & Security > Photos'" + ) + return 1 # initialize export flags # by default, will export all versions of photos unless skip flag is set diff --git a/osxphotos/disclaim.py b/osxphotos/disclaim.py new file mode 100644 index 000000000..3df17e8d3 --- /dev/null +++ b/osxphotos/disclaim.py @@ -0,0 +1,56 @@ +""" Disclaim the application when running on macOS so that permission requests come from the application itself +instead of the terminal. + +To use this, the libdisclaim.dylib library must be built and placed in the same directory as this file +or provided as an argument to the disclaim function. + +Reference: http://qt.io/blog/the-curious-case-of-the-responsible-process +""" + +import ctypes +import os +import sys + + +def pyinstaller() -> bool: + """Return True if the application is running from a PyInstaller bundle.""" + # PyInstaller bootloader sets a flag + return hasattr(sys, "_MEIPASS") + + +def pyapp() -> bool: + """Check if we are running in a pyapp environment.""" + return os.environ.get("PYAPP") == "1" + + +def disclaim(library_path: str | None = None): + """Run this function to disclaim the application and set the responsible process to the caller. + + Args: + library_path: The path to the libdisclaim.dylib library. + If not provided, libdisclaim.dylib will be loaded from the the same directory as this file. + """ + + if sys.platform != "darwin": + return + + # Avoid redundant disclaims + env_marker = f"PY_DISCLAIMED-{sys.argv[0]}" + if os.environ.get(env_marker): + return + os.environ[env_marker] = "1" + + if pyinstaller(): + # If running from pyinstaller, the _MEIPASS2 environment variable is set + # The bootloader has cleared the _MEIPASS2 environment variable by the + # time we get here, which means re-launching the executable disclaimed + # will unpack the binary again. To avoid this we reset _MEIPASS2 again, + # so that our re-launch will pick up at second stage of the bootstrap. + os.environ["_MEIPASS2"] = sys._MEIPASS + + # Load the disclaim library and call the disclaim function + library_path = library_path or os.path.join( + os.path.dirname(__file__), "libdisclaim.dylib" + ) + libdisclaim = ctypes.cdll.LoadLibrary(library_path) + libdisclaim.disclaim() diff --git a/osxphotos/photokit_utils.py b/osxphotos/photokit_utils.py new file mode 100644 index 000000000..8122d87fc --- /dev/null +++ b/osxphotos/photokit_utils.py @@ -0,0 +1,29 @@ +""" Utilities for working with the Photokit framework on macOS """ + +import time + +from osxphotos.platform import is_macos + +if is_macos: + from osxphotos.photokit import ( + check_photokit_authorization, + request_photokit_authorization, + ) + +# seconds to wait for user to grant authorization +WAIT_FOR_AUTHORIZATION_TIMEOUT = 10 + +# seconds to sleep between authorization check +AUTHORIZATION_SLEEP = 0.25 + +def wait_for_photokit_authorization() -> bool: + """Request and wait for authorization to access Photos library.""" + if check_photokit_authorization(): + return True + start_time = time.time() + request_photokit_authorization() + while not check_photokit_authorization(): + time.sleep(AUTHORIZATION_SLEEP) + if time.time() > start_time + WAIT_FOR_AUTHORIZATION_TIMEOUT: + return False + return True diff --git a/osxphotos/utils.py b/osxphotos/utils.py index e481dc5e6..820f98717 100644 --- a/osxphotos/utils.py +++ b/osxphotos/utils.py @@ -662,3 +662,11 @@ def download_url_to_temp_dir(url: str) -> str: # these files will be deleted when the system cleans the temp directory (usually on reboot) tmpdir = tempdir.tempdir("downloads") return download_url_to_dir(url, tmpdir, unique=True) + + +def terminal() -> str: + """Return name of terminal app that launched the calling script or empty string if terminal cannot be determined. + + Note: This only works on macOS + """ + return os.environ.get("TERM_PROGRAM", "")