From 6f91d364fa5eaa964084206d938c81de6476425d Mon Sep 17 00:00:00 2001 From: Dingwen Wang Date: Sun, 9 Jun 2024 20:43:32 -0400 Subject: [PATCH] Release AutoArchive 0.0.4 - feat: drag to archive/revert (requires PyQt6) - utility included to patch PyQt6-Qt6 with Universal 2 binary using `lipo` --- auto_archive.py | 131 +++++++++++++++++++++++++++++++++------ build-universal.sh | 7 ++- build.sh | 6 ++ pyqt6-universal-patch.sh | 37 +++++++++++ release.sh | 6 ++ requirements.txt | 1 + setup.py | 19 +++++- 7 files changed, 185 insertions(+), 22 deletions(-) create mode 100755 pyqt6-universal-patch.sh diff --git a/auto_archive.py b/auto_archive.py index 8d6f259..a20d8f4 100644 --- a/auto_archive.py +++ b/auto_archive.py @@ -1,4 +1,5 @@ import os +import sys import platform import shutil import time @@ -6,11 +7,41 @@ import tempfile import subprocess +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import QEvent, QTimer ARCHIVE_FOLDER = 'Archive' ARCHIVE_THRESHOLD = 30 # days ERROR_LOG_FILE = os.path.join(tempfile.gettempdir(), 'auto_archive.log') LOG_LEVEL = 'INFO' # INIT, INFO, ERROR +API_LEVEL = 1 +API_COMPATIBLE = [None, 1] +EXEC_LOG = [] + + +class HandleOpenDocument(QApplication): # subclass the QApplication class + def __init__(self, argv): + super().__init__(argv) + self.apple_event_open_document = False + self.apple_event_open_document_path = '' + + # Setup a timer to wait for file open events + self.timer = QTimer(self) + self.timer.setSingleShot(True) + self.timer.timeout.connect(self.check_file_received) + self.timer.start(1000) # Wait for 1 second before checking + + def event(self, event: QEvent): # override the event method + if event.type() == QEvent.Type.FileOpen: # filter the File Open event + file_path = event.file() # get the file name from the event + self.apple_event_open_document = True + self.apple_event_open_document_path = file_path + return True # Mark the event as handled + return super().event(event) + + def check_file_received(self): + self.quit() # Quit the application + def get_path(): return os.path.dirname(__file__) @@ -31,6 +62,12 @@ def err_log(msg, log_type='ERROR'): f.write('\n') else: f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())} {log_type}: {msg}\n") + EXEC_LOG.append((f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}", log_type, str(msg))) + +def exit_log(ret: int): + err_log('Process ended', log_type='INIT') + err_log('', log_type='INIT') + exit(ret) def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): err_log(f'Reverting {run_log}', log_type='INIT') @@ -42,15 +79,21 @@ def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): err_log(e) return + log_api = log.get('api', None) + if log_api not in API_COMPATIBLE: + err_log(f'Incompatible API: {log_api}, STOP', log_type='ERROR') + return + for item in log['moved_files']: try: - src = os.path.join(target_dir, archive_folder, item['dest']) - dest = os.path.join(target_dir, item['src']) - err_log(f'Reverting {src} to {dest}', log_type='INFO') + dst_t = item['dest'] if 'dest' in item else item['dst'] # backward compatibility + src = os.path.join(target_dir, archive_folder, dst_t) + dst = os.path.join(target_dir, item['src']) + err_log(f'Reverting {src} to {dst}', log_type='INFO') if os.path.isdir(src): - shutil.copytree(src, dest) + shutil.copytree(src, dst) else: - shutil.copy2(src, dest) + shutil.copy2(src, dst) except Exception as e: err_log(e) continue @@ -59,7 +102,9 @@ def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): if __name__ == '__main__': - err_log('Process started', log_type='INIT') + err_log(f'Process started with arguments: {sys.argv}', log_type='INIT') + # OS X App Bundle + osx_app_bundle = False # Mac OS Date Added osx_date_added = True by_osx_date_added = True @@ -71,6 +116,7 @@ def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): err_log('Running in OS X App Bundle', log_type='INIT') self_name = os.path.basename(target_dir) target_dir = os.path.dirname(target_dir) + osx_app_bundle = True else: self_name = os.path.basename(__file__) else: @@ -81,6 +127,35 @@ def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): err_log(f'Target directory: {target_dir}', log_type='INIT') + # handle Open Document Apple Event + open_document = False + open_document_revert = '' + if osx_app_bundle: + app = HandleOpenDocument(sys.argv) + app.exec() + if app.apple_event_open_document: + err_log(f'Apple Event Open Document Path: {app.apple_event_open_document_path}', log_type='INIT') + open_document = True + # received a file + # if directory, use as target_dir + # if end with .json, use as revert file, the target_dir is .json file/../../.. + if os.path.isdir(app.apple_event_open_document_path): + err_log('Archive folder received', log_type='INIT') + target_dir = app.apple_event_open_document_path + elif app.apple_event_open_document_path.endswith('.json'): + err_log('Revert file received', log_type='INIT') + open_document_revert = app.apple_event_open_document_path + target_dir = os.path.dirname(app.apple_event_open_document_path) + target_dir = os.path.dirname(target_dir) + target_dir = os.path.dirname(target_dir) + target_dir = os.path.dirname(target_dir) + else: + err_log('Invalid Apple Event Open Document Path', log_type='ERROR') + err_log(f'Target directory: {target_dir}', log_type='INIT') + else: + err_log('No Apple Event Open Document Path', log_type='INIT') + + # get config from config file config = { 'archive_folder': ARCHIVE_FOLDER, @@ -109,14 +184,22 @@ def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): config = {**config, **load_config} else: err_log(f'Config file not found, creating {config_file}', log_type='INIT') + if open_document: + err_log('Open Document received but no config file found, STOP preventing accidental D&D', log_type='ERROR') + exit_log(1) with open(config_file, 'w', encoding='utf-8') as f: json.dump(config, f, indent=4) except Exception as e: err_log(e) - exit() + exit_log(1) err_log(f'Config: {config}', log_type='INIT') + # set revert file + if open_document_revert != '': + config['revert'] = open_document_revert + err_log(f'Config updated with revert file: {open_document_revert}', log_type='INIT') + archive_folder = config['archive_folder'] archive_threshold = config['archive_threshold'] debug_mode = config['debug'] @@ -141,13 +224,16 @@ def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): if 'revert' in config: if os.path.exists(config['revert']): err_log('Revert mode', log_type='INIT') + # check if revert file under target_dir/archive_folder + revert_base = os.path.basename(config['revert']) + if os.path.join(target_dir, archive_folder, 'LOG', 'RUN', revert_base) != config['revert']: + err_log(f'Revert file {config["revert"]} not under {target_dir}/{archive_folder}', log_type='ERROR') + exit_log(1) revert(config['revert'], target_dir, archive_folder) # move the json to reverted.json file_name, file_ext = os.path.splitext(config['revert']) - shutil.move(config['revert'], file_name + '_reverted' + file_ext) - err_log('Process ended', log_type='INIT') - err_log('', log_type='INIT') - exit() + shutil.move(config['revert'], f'{file_name}_reverted{file_ext}') + exit_log(0) # check if archive folder exists archive_dir = os.path.join(target_dir, archive_folder) @@ -156,9 +242,7 @@ def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): os.mkdir(archive_dir) except Exception as e: err_log(e) - err_log('Process ended', log_type='INIT') - err_log('', log_type='INIT') - exit() + exit_log(1) # check all files under target directory @@ -223,21 +307,29 @@ def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): file_archive_path = os.path.join(file_archive_dir, file) err_log(f'Init file archive path: {file_archive_path}', log_type='INFO') file_relative = file + file_name, file_ext = os.path.splitext(file) + i = 1 + while os.path.exists(file_archive_path): + file_relative = f'{file_name} ({i}){file_ext}' + file_archive_path = os.path.join(file_archive_dir, file_relative) + i += 1 + ''' if os.path.exists(file_archive_path): file_name, file_ext = os.path.splitext(file) i = 1 while True: - file_relative = file_name + ' (' + str(i) + ')' + file_ext + file_relative = f'{file_name} ({i}){file_ext}' file_archive_path = os.path.join(file_archive_dir, file_relative) if not os.path.exists(file_archive_path): break i += 1 + ''' err_log(f'Final file archive path: {file_archive_path}', log_type='INFO') shutil.move(file_path, file_archive_path) # success moved_files.append({ 'src': file, - 'dest': os.path.join(file_archive_dir_relative, file_relative) + 'dst': os.path.join(file_archive_dir_relative, file_relative) }) except Exception as e: # save error to debug log @@ -275,12 +367,13 @@ def revert(run_log, target_dir, archive_folder=ARCHIVE_FOLDER): try: with open(run_log_file, 'w') as f: json.dump({ + 'api': API_LEVEL, 'start_time': start_time, 'end_time': end_time, - 'moved_files': moved_files + 'moved_files': moved_files, + 'exec_log': EXEC_LOG }, f, indent=4) except Exception as e: err_log(e) - err_log('Process ended', log_type='INIT') - err_log('', log_type='INIT') + exit_log(0) diff --git a/build-universal.sh b/build-universal.sh index f9670f9..f06cfcb 100755 --- a/build-universal.sh +++ b/build-universal.sh @@ -11,13 +11,14 @@ PYTHON=/usr/bin/python3 export PATH=/usr/bin:$PATH # Hello -echo "AutoArchive Universal Build Utility" +echo "AutoArchive Universal 2 Binary Build Utility" if [ "$(arch)" == "arm64" ]; then echo "Running on arm64, switching to x86_64" arch -x86_64 $0 exit fi + echo "Running on $(arch)" echo "Build Directory: $BUILD_DIR" @@ -53,9 +54,11 @@ echo "Installing dependencies..." source ./venv/bin/activate pip install -r requirements.txt deactivate +echo "Patching PyQt6..." +./pyqt6-universal-patch.sh echo "Running Build Utility..." ./build.sh -echo "Build Utility complete." +echo "Universal 2 Binary Build Utility complete." echo "Run 'cd ./build/universal' for next steps." diff --git a/build.sh b/build.sh index 66ed51b..6ffbfb6 100755 --- a/build.sh +++ b/build.sh @@ -26,4 +26,10 @@ fi source ./venv/bin/activate python3 setup.py py2app + +deactivate + +echo "Copying artifact to ./test" cp -R ./dist/AutoArchive.app ./test/AutoArchive.app + +echo "Build Utility complete." diff --git a/pyqt6-universal-patch.sh b/pyqt6-universal-patch.sh new file mode 100755 index 0000000..f0f4552 --- /dev/null +++ b/pyqt6-universal-patch.sh @@ -0,0 +1,37 @@ +#!/bin/bash + +# functions +trash() { osascript -e "tell application \"Finder\" to delete POSIX file \"$(realpath "$1")\"" > /dev/null; } + +echo "PyQt Universal 2 Binary Patch Utility" + +if [[ "$(sysctl -n machdep.cpu.brand_string)" != *"Apple"* ]]; then + echo "This script must be run on an Apple Silicon Mac. Nothing to do." + echo "PyQt Universal 2 Binary Patch Utility complete." + exit +fi + +echo "Current Directory: $(pwd)" + +echo "Activating virtual environment..." +source ./venv/bin/activate + +SITE_PACKAGE=$(python -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())") +echo "Site Package: $SITE_PACKAGE" + +echo "Patching PyQt6..." +# make a backup, preserve permissions +cp -R $SITE_PACKAGE/PyQt6 $SITE_PACKAGE/PyQt6.bak + +# reinstall PyQt6 as arm64 +arch -arm64 pip install PyQt6 --force-reinstall + +# Patch +cd $SITE_PACKAGE +echo "Converting to Universal Binary..." +find ./PyQt6/Qt6 -type f -perm +111 -exec sh -c 'xcrun lipo -create -output "{}" "{}" "$(echo "{}" | sed "s|^./PyQt6/|./PyQt6.bak/|")"' \; +echo "Patching complete." + +deactivate + +echo "PyQt Universal 2 Binary Patch Utility complete." diff --git a/release.sh b/release.sh index 57269ad..f5143b2 100755 --- a/release.sh +++ b/release.sh @@ -29,7 +29,11 @@ read -p "Press Enter to continue to codesign" # codesign CODESIGN_VARS="--deep --force --verify --verbose --timestamp --options runtime" +echo Codesigning libraries... find $source/ -name "*.so" -exec codesign $CODESIGN_VARS -s "$TEAM_ID" {} \; +find $source/ -name "*.dylib" -exec codesign $CODESIGN_VARS -s "$TEAM_ID" {} \; +echo Codesigning executables... +find $source/ -type f -perm +111 -exec codesign $CODESIGN_VARS -s "$TEAM_ID" {} \; codesign $CODESIGN_VARS -s "$TEAM_ID" $source echo . @@ -50,3 +54,5 @@ spctl -vvv --assess --type exec $source trash $source_parent/AutoArchive.zip /usr/bin/ditto -c -k --keepParent $source $source_parent/AutoArchive.zip +mkdir -p $source_parent/AutoArchive +cp -R $source $source_parent/AutoArchive/AutoArchive.app diff --git a/requirements.txt b/requirements.txt index bd50af2..fba62bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ py2app~=0.28.7 setuptools<70.0.0 wheel +pyqt6 diff --git a/setup.py b/setup.py index 572b06e..b5d16b5 100644 --- a/setup.py +++ b/setup.py @@ -11,17 +11,34 @@ APP = ['auto_archive.py'] DATA_FILES = [] -VERSION = '0.0.3' +VERSION = '0.0.4' PLIST = { 'CFBundleShortVersionString': VERSION, 'CFBundleGetInfoString': f'AutoArchive {VERSION}', 'CFBundleIdentifier': 'net.extrawdw.AutoArchive', 'NSHumanReadableCopyright': f'Copyright © {datetime.now().year} Dingwen Wang. All rights reserved.', + # support JSON file as input + 'CFBundleDocumentTypes': [ + { + 'CFBundleTypeExtensions': ['json'], + 'CFBundleTypeName': 'Run Log JSON', + 'CFBundleTypeRole': 'Editor', + 'LSItemContentTypes': ['public.json'], + }, + { + 'CFBundleTypeExtensions': [], + 'CFBundleTypeName': 'Folder', + 'CFBundleTypeRole': 'Editor', + 'LSItemContentTypes': ['public.folder'], + } + ], } + OPTIONS = { 'iconfile': '/System/Library/CoreServices/CoreTypes.bundle/Contents/Resources/AllMyFiles.icns', 'plist': PLIST, + 'arch': 'universal2', } setup(