diff --git a/FCP-BeautyGirl/FCP-BeautyGirl.xcodeproj/project.pbxproj b/FCP-BeautyGirl/FCP-BeautyGirl.xcodeproj/project.pbxproj index 61780d1..3055ecd 100644 --- a/FCP-BeautyGirl/FCP-BeautyGirl.xcodeproj/project.pbxproj +++ b/FCP-BeautyGirl/FCP-BeautyGirl.xcodeproj/project.pbxproj @@ -22,14 +22,14 @@ containerPortal = 6945FC833088B7220013E108 /* Project object */; proxyType = 1; remoteGlobalIDString = 6945FC953088B7220013E108; - remoteInfo = "XPC Service"; + remoteInfo = "FCP-BeautyGirl XPC Service"; }; 6945FCA43088B7220013E108 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 6945FC833088B7220013E108 /* Project object */; proxyType = 1; remoteGlobalIDString = 6945FC953088B7220013E108; - remoteInfo = "XPC Service"; + remoteInfo = "FCP-BeautyGirl XPC Service"; }; /* End PBXContainerItemProxy section */ @@ -174,8 +174,8 @@ fileSystemSynchronizedGroups = ( 6945FCA63088B7220013E108 /* FCP-BeautyGirl */, ); - name = "Wrapper Application"; - productName = "Wrapper Application"; + name = "FCP-BeautyGirl"; + productName = "FCP-BeautyGirl"; productReference = 6945FC8B3088B7220013E108 /* FCP-BeautyGirl.app */; productType = "com.apple.product-type.application"; }; @@ -194,8 +194,8 @@ fileSystemSynchronizedGroups = ( 6945FCA63088B7220013E108 /* FCP-BeautyGirl */, ); - name = "XPC Service"; - productName = "XPC Service"; + name = "FCP-BeautyGirl XPC Service"; + productName = "FCP-BeautyGirl XPC Service"; productReference = 6945FC963088B7220013E108 /* FCP-BeautyGirl XPC Service.pluginkit */; productType = "com.apple.product-type.bundle"; }; @@ -323,7 +323,7 @@ ); MACH_O_TYPE = mh_execute; PRODUCT_BUNDLE_IDENTIFIER = com.dayu.fcpbeautygirl.fxplug; - PRODUCT_NAME = "FCP-BeautyGirl $(TARGET_NAME)"; + PRODUCT_NAME = "FCP-BeautyGirl XPC Service"; SWIFT_OBJC_BRIDGING_HEADER = "FCP-BeautyGirl/Plugin/XPC Service-Bridging-Header.h"; WRAPPER_EXTENSION = pluginkit; }; @@ -346,7 +346,7 @@ ); MACH_O_TYPE = mh_execute; PRODUCT_BUNDLE_IDENTIFIER = com.dayu.fcpbeautygirl.fxplug; - PRODUCT_NAME = "FCP-BeautyGirl $(TARGET_NAME)"; + PRODUCT_NAME = "FCP-BeautyGirl XPC Service"; SWIFT_OBJC_BRIDGING_HEADER = "FCP-BeautyGirl/Plugin/XPC Service-Bridging-Header.h"; WRAPPER_EXTENSION = pluginkit; }; diff --git a/product.json b/product.json index c138aa4..52e22a6 100644 --- a/product.json +++ b/product.json @@ -9,7 +9,7 @@ "sourceRoot": "FCP-BeautyGirl", "sourceDirectory": "FCP-BeautyGirl/FCP-BeautyGirl", "xcodeProject": "FCP-BeautyGirl/FCP-BeautyGirl.xcodeproj", - "scheme": "Wrapper Application", + "scheme": "FCP-BeautyGirl", "installationPath": "/Library/Plug-Ins/FxPlug/FCP-BeautyGirl.app", "filterUUID": "FB14B0E6-59B2-4836-916F-CD0C559E9241", "teamIdentifier": "B3W25FX5H5", diff --git a/scripts/deliver.py b/scripts/deliver.py index 3a2facd..c0752c5 100755 --- a/scripts/deliver.py +++ b/scripts/deliver.py @@ -3,26 +3,24 @@ The final prompt keeps the lock held while native-host validation is performed. """ import argparse, datetime, json, os, pathlib, plistlib, re, shutil, subprocess, sys, time +from product_identity import PRODUCT, LegacyMigration, delivery_locks, validate_app root=pathlib.Path(__file__).resolve().parent.parent parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('--wait-for-hosts',action='store_true',help='After notarization, retain the delivery lock and wait for Motion/FCP to exit naturally before installing; never control or quit either host.') parser.add_argument('--user-tests',action='store_true',help='After non-UI installed-app verification, record native effect QA as pending user testing and clean known generated copies without the interactive validation prompt.') parser.add_argument('--resume-prepared',action='store_true',help='Resume an already Accepted and stapled staged app after checking its recorded signature, version and unchanged product source.') args=parser.parse_args() -bundle_id='com.dayu.fcpbeautygirl' -lock=pathlib.Path('/Users/dayu/.codex/app-delivery-locks')/(bundle_id+'.lock') -lock.parent.mkdir(parents=True,exist_ok=True) -try:lock.mkdir() -except FileExistsError:raise SystemExit('Delivery lock occupied; inspect owner.json and the owner process') -(lock/'owner.json').write_text(json.dumps({'pid':os.getpid(),'cwd':str(root),'bundle_id':bundle_id})) -staging=pathlib.Path('/tmp/paintfcp-build-20261021') +bundle_id=PRODUCT['bundleIdentifier'] +locks=delivery_locks();locks.__enter__() +staging=pathlib.Path('/tmp/fcp-beautygirl-release') logs=root/'build'/'delivery';logs.mkdir(parents=True,exist_ok=True) -app=staging/'Build/Products/Release/FCP-BeautyGirl.app' -installed=pathlib.Path('/Library/Plug-Ins/FxPlug/FCP-BeautyGirl.app') +app=staging/'Build/Products/Release'/(PRODUCT['name']+'.app') +installed=pathlib.Path(PRODUCT['installationPath']) archive=logs/'notarization.zip' identity='Developer ID Application: zhong luo (B3W25FX5H5)' keychain='/Users/dayu/Library/Keychains/login.keychain-db' previous=logs/'previous-FCP-BeautyGirl.app' +migration=LegacyMigration(logs) receipt_path=logs/'delivery-receipt.json' receipt={'status':'preparing','bundleIdentifier':bundle_id,'installationPath':str(installed), 'options':{'waitForHosts':args.wait_for_hosts,'userTests':args.user_tests}, @@ -80,7 +78,7 @@ def cleanup_known_products(): try: delivery_source={'commit':run(['git','rev-parse','HEAD']).strip(), 'trackedWorktreeChanges':run(['git','status','--porcelain','--untracked-files=no']).splitlines()} - plugin=app/'Contents/PlugIns/FCP-BeautyGirl XPC Service.pluginkit' + plugin=app/'Contents/PlugIns'/PRODUCT['pluginName'] if args.resume_prepared: old_receipt=json.loads(receipt_path.read_text()) result=old_receipt.get('notarization',{}) @@ -94,7 +92,7 @@ try: if not expected or not actual or expected.group(1)!=actual.group(1):raise RuntimeError('Prepared signature hash changed') if info.get('CFBundleShortVersionString')!=old_receipt.get('version') or info.get('CFBundleVersion')!=old_receipt.get('build'):raise RuntimeError('Prepared version changed') source_commit=old_receipt.get('source',{}).get('commit') - if not source_commit or run(['git','diff',source_commit,'--','FCP-BeautyGirl']).strip():raise RuntimeError('Product source changed since prepared build') + if not source_commit or run(['git','diff',source_commit,'--',PRODUCT['sourceRoot'],'product.json']).strip():raise RuntimeError('Product source changed since prepared build') receipt.update(old_receipt);receipt.pop('failure',None) receipt['resumedBy']=delivery_source receipt['options']={'waitForHosts':args.wait_for_hosts,'userTests':args.user_tests,'resumePrepared':True} @@ -107,14 +105,14 @@ try: identities=run(['security','find-identity','-v','-p','codesigning',keychain]) if identity not in identities:raise RuntimeError('Valid Developer ID Application identity is unavailable') print('Building universal Release…',flush=True) - run(['xcodebuild','-project','FCP-BeautyGirl/FCP-BeautyGirl.xcodeproj','-scheme','Wrapper Application','-configuration','Release','-derivedDataPath',str(staging),'-destination','generic/platform=macOS','build'],logs/'build.log') + run(['xcodebuild','-project',PRODUCT['xcodeProject'],'-scheme',PRODUCT['scheme'],'-configuration','Release','-derivedDataPath',str(staging),'-destination','generic/platform=macOS','build'],logs/'build.log') # Xcode can register a fresh build. Remove this known generated path before # lengthy signing/notarization while a user may still be testing the old app. unregister_generated_plugin(plugin) for framework in sorted((plugin/'Contents/Frameworks').glob('*.framework')): run(['codesign','--force','--timestamp','--options','runtime','--sign',identity,str(framework)]) run(['codesign','--force','--timestamp','--options','runtime','--sign',identity,str(plugin)]) - run(['codesign','--force','--timestamp','--options','runtime','--entitlements',str(root/'FCP-BeautyGirl/FCP-BeautyGirl/Wrapper Application/SandboxEntitlements.entitlements'),'--sign',identity,str(app)]) + run(['codesign','--force','--timestamp','--options','runtime','--entitlements',str(root/PRODUCT['sourceDirectory']/'Wrapper Application/SandboxEntitlements.entitlements'),'--sign',identity,str(app)]) run(['codesign','--verify','--deep','--strict',str(app)]) info=require_product(app) receipt['version']=info.get('CFBundleShortVersionString');receipt['build']=info.get('CFBundleVersion') @@ -148,6 +146,8 @@ try: if installed.exists(): shutil.move(str(installed),str(previous)) try: + validate_app(app,bundle_id) + migration.relocate(unregister_generated_plugin) shutil.copytree(app,installed,symlinks=True) run(['codesign','--verify','--deep','--strict',str(installed)]) run(['xcrun','stapler','validate',str(installed)]) @@ -156,21 +156,23 @@ try: signature=subprocess.run(['codesign','-dvvv',str(installed)],capture_output=True,text=True,check=True).stderr if info.get('CFBundleIdentifier')!=bundle_id or 'TeamIdentifier=B3W25FX5H5' not in signature:raise RuntimeError('Installed app identity mismatch') (logs/'installed-signature.txt').write_text(signature) - run(['pluginkit','-a',str(installed/'Contents/PlugIns/FCP-BeautyGirl XPC Service.pluginkit')]) + run(['pluginkit','-a',str(installed/'Contents/PlugIns'/PRODUCT['pluginName'])]) # Xcode may register its build product. Only the installed plugin should # be discoverable while native host validation is in progress. unregister_generated_plugin(plugin) - installed_plugin=installed/'Contents/PlugIns/FCP-BeautyGirl XPC Service.pluginkit' + installed_plugin=installed/'Contents/PlugIns'/PRODUCT['pluginName'] plugin_info=plistlib.loads((installed_plugin/'Contents/Info.plist').read_bytes()) discovered=run(['pluginkit','-m','-A','-D','-v','-i',plugin_info['CFBundleIdentifier']]) if str(installed_plugin) not in discovered:raise RuntimeError('PluginKit did not report the formal installed plug-in path') (logs/'installed-discovery.txt').write_text(discovered) save_receipt('installed',installedSignature=signature,installedSignatureVerified=True, installedStapleValidated=True,gatekeeperAccepted=True, + identityMigration={'legacyInstallationsRemoved':[str(source) for legacy,source,backup in migration.moved], 'filterUUIDPreserved':PRODUCT['filterUUID']}, pluginDiscovery={'bundleIdentifier':plugin_info['CFBundleIdentifier'],'formalPath':str(installed_plugin),'output':discovered}) except Exception: if installed.exists():shutil.rmtree(installed) if previous.exists():shutil.move(str(previous),str(installed)) + migration.rollback(lambda path:run(['pluginkit','-a',str(path)])) raise print(json.dumps({'status':'installed','path':str(installed),'notarization':result}),flush=True) if args.user_tests: @@ -179,11 +181,11 @@ try: input('HOST_VALIDATION_PENDING: press Return only after host verification and duplicate inventory.\n') receipt['nativeEffectQA']={'status':'operator_confirmed','performedByScript':False} # Only this task’s known generated product copies are removed. - removed=cleanup_known_products() + removed=migration.cleanup()+cleanup_known_products() save_receipt('installed_user_qa_pending' if args.user_tests else 'complete',removedGeneratedPaths=removed) print('Delivery staging and notarization archive cleaned.',flush=True) except BaseException as error: save_receipt('failed',failure={'type':type(error).__name__,'message':str(error)}) raise finally: - (lock/'owner.json').unlink();lock.rmdir() + locks.__exit__(None,None,None) diff --git a/scripts/installer_identity.py b/scripts/installer_identity.py new file mode 100644 index 0000000..9ced2ff --- /dev/null +++ b/scripts/installer_identity.py @@ -0,0 +1,83 @@ +"""Add this product's reversible identity migration to PKG Forge's postinstall.""" +from product_identity import PRODUCT + +def inject_migration(post, product=PRODUCT): + legacy, = product['legacyProducts'] + block = r''' +# FCP-BeautyGirl identity migration. Payload and template work are already complete. +BG_NEW_APP="${TARGET_VOLUME%/}@@NEW_PATH@@" +BG_OLD_APP="${TARGET_VOLUME%/}@@OLD_PATH@@" +BG_BACKUP_DIR="" +BG_OLD_MOVED=0 +bg_validate_identity() { + local app="$1" expected="$2" plugin="$3" actual signature uuid + [[ -d "$app" && ! -L "$app" ]] || return 1 + actual="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$app/Contents/Info.plist")" || return 1 + [[ "$actual" == "$expected" ]] || return 1 + /usr/bin/codesign --verify --all-architectures --deep --strict "$app" || return 1 + signature="$(/usr/bin/codesign -dvvv "$app" 2>&1)" || return 1 + [[ "$signature" == *'TeamIdentifier=@@TEAM@@'* ]] || return 1 + uuid="$(/usr/libexec/PlistBuddy -c 'Print :ProPlugPlugInList:0:uuid' "$app/Contents/PlugIns/$plugin/Contents/Info.plist")" || return 1 + [[ "$uuid" == '@@UUID@@' ]] +} +bg_unregister_identity() { + # Both bootstrap session and Unix identity belong to the desktop user. + /bin/launchctl asuser "$CONSOLE_UID" /usr/bin/sudo -u "$CONSOLE_USER" /usr/bin/pluginkit -r "$1" >/dev/null 2>&1 || true +} +bg_rollback_identity() { + local result=$? + trap - EXIT + if (( result != 0 && BG_OLD_MOVED )); then + bg_validate_identity "$BG_NEW_APP" '@@NEW_ID@@' '@@NEW_PLUGIN@@' || { + /bin/echo "新 APP 身份已变化,旧版保留在 $BG_BACKUP_DIR" >&2 + exit "$result" + } + bg_unregister_identity "$BG_NEW_APP/Contents/PlugIns/@@NEW_PLUGIN@@" + /bin/rm -rf "$BG_NEW_APP" + /bin/mv "$BG_BACKUP_DIR/@@OLD_NAME@@.app" "$BG_OLD_APP" + /bin/rmdir "$BG_BACKUP_DIR" + fx_verify_app "$BG_OLD_APP" 0 'Contents/PlugIns/@@OLD_PLUGIN@@' '@@OLD_PLUGIN_ID@@' '1.1' || true + fi + exit "$result" +} +bg_finish_identity() { + if (( BG_OLD_MOVED )); then + bg_validate_identity "$BG_BACKUP_DIR/@@OLD_NAME@@.app" '@@OLD_ID@@' '@@OLD_PLUGIN@@' + /bin/rm -rf "$BG_BACKUP_DIR/@@OLD_NAME@@.app" + /bin/rmdir "$BG_BACKUP_DIR" + fi + trap - EXIT +} +bg_validate_identity "$BG_NEW_APP" '@@NEW_ID@@' '@@NEW_PLUGIN@@' || { + /bin/echo '新版 APP 签名或身份验证失败,未迁移旧版。' >&2 + exit 1 +} +if [[ -e "$BG_OLD_APP" || -L "$BG_OLD_APP" ]]; then + bg_validate_identity "$BG_OLD_APP" '@@OLD_ID@@' '@@OLD_PLUGIN@@' || { + /bin/echo '旧路径的 APP 身份不属于本产品,未移动或删除。' >&2 + exit 1 + } + BG_RECOVERY_ROOT="${TARGET_VOLUME%/}/Library/Application Support/@@NAME@@/Migration" + /bin/mkdir -p "$BG_RECOVERY_ROOT" + BG_BACKUP_DIR="$(/usr/bin/mktemp -d "$BG_RECOVERY_ROOT/.upgrade-XXXXXXXX")" + bg_unregister_identity "$BG_OLD_APP/Contents/PlugIns/@@OLD_PLUGIN@@" + /bin/mv "$BG_OLD_APP" "$BG_BACKUP_DIR/@@OLD_NAME@@.app" + BG_OLD_MOVED=1 +fi +trap bg_rollback_identity EXIT +''' + values = {'NEW_PATH': product['installationPath'], 'OLD_PATH': legacy['installationPath'], + 'NEW_ID': product['bundleIdentifier'], 'OLD_ID': legacy['bundleIdentifier'], + 'NEW_PLUGIN': product['pluginName'], 'OLD_PLUGIN': legacy['pluginName'], + 'OLD_PLUGIN_ID': legacy['pluginBundleIdentifier'], 'OLD_NAME': legacy['name'], + 'NAME': product['name'], 'TEAM': product['teamIdentifier'], 'UUID': product['filterUUID']} + for key, value in values.items(): + if any(c in value for c in ['"', "'", '\n', '$', '`']): + raise ValueError('Unsafe installer identity value: ' + key) + block = block.replace('@@' + key + '@@', value) + marker = '\ntrap - EXIT\nfx_verify_app ' + if post.count(marker) != 1 or not post.rstrip().endswith('exit 0'): + raise ValueError('Unexpected PKG Forge postinstall structure') + post = post.replace(marker, '\ntrap - EXIT\n' + block + '\nfx_verify_app ', 1) + end = post.rfind('exit 0') + return post[:end] + 'bg_finish_identity\n' + post[end:] diff --git a/scripts/product_identity.py b/scripts/product_identity.py new file mode 100644 index 0000000..4c7d299 --- /dev/null +++ b/scripts/product_identity.py @@ -0,0 +1,81 @@ +"""Shared product identity and reversible, identity-checked legacy APP migration.""" +import contextlib, json, os, pathlib, plistlib, shutil, subprocess + +ROOT = pathlib.Path(__file__).resolve().parent.parent +PRODUCT = json.loads((ROOT / 'product.json').read_text()) + +def validate_app(path, bundle_id, team=PRODUCT['teamIdentifier']): + path = pathlib.Path(path) + info = plistlib.loads((path / 'Contents/Info.plist').read_bytes()) + if info.get('CFBundleIdentifier') != bundle_id: + raise RuntimeError('APP identity mismatch: ' + str(path)) + subprocess.run(['codesign', '--verify', '--all-architectures', '--deep', '--strict', str(path)], check=True, capture_output=True) + signature = subprocess.run(['codesign', '-dvvv', str(path)], check=True, text=True, capture_output=True).stderr + if ('TeamIdentifier=' + team) not in signature: + raise RuntimeError('APP signing team mismatch: ' + str(path)) + plugins = list((path / 'Contents/PlugIns').glob('*.pluginkit')) + if not any(PRODUCT['filterUUID'] in [p.get('uuid') for p in plistlib.loads((plugin / 'Contents/Info.plist').read_bytes()).get('ProPlugPlugInList', [])] for plugin in plugins): + raise RuntimeError('APP does not contain this product filter UUID: ' + str(path)) + return info + +@contextlib.contextmanager +def delivery_locks(product=PRODUCT): + """Hold both old and new identity locks throughout an identity migration.""" + base = pathlib.Path('/Users/dayu/.codex/app-delivery-locks') + base.mkdir(parents=True, exist_ok=True) + held = [] + try: + for bundle_id in sorted({product['bundleIdentifier']} | {p['bundleIdentifier'] for p in product.get('legacyProducts', [])}): + lock = base / (bundle_id + '.lock') + lock.mkdir() + held.append(lock) + (lock / 'owner.json').write_text(json.dumps({'pid': os.getpid(), 'cwd': str(ROOT), 'bundle_id': bundle_id, 'product': product['name']})) + yield + finally: + for lock in reversed(held): + (lock / 'owner.json').unlink(missing_ok=True) + lock.rmdir() + +class LegacyMigration: + def __init__(self, backup_root, *, product=PRODUCT, volume=pathlib.Path('/'), verifier=validate_app): + self.product, self.backup_root, self.verifier = product, pathlib.Path(backup_root), verifier + self.volume, self.moved = pathlib.Path(volume), [] + + def candidates(self): + result = [] + for legacy in self.product.get('legacyProducts', []): + source = self.volume / pathlib.Path(legacy['installationPath']).relative_to('/') + if source.exists(): + self.verifier(source, legacy['bundleIdentifier']) + backup = self.backup_root / ('legacy-' + legacy['name'] + '.app') + if backup.exists(): + raise RuntimeError('Legacy recovery copy already exists: ' + str(backup)) + result.append((legacy, source, backup)) + return result + + def relocate(self, unregister): + # Validate every candidate before moving any of them. + for legacy, source, backup in self.candidates(): + self.verifier(source, legacy['bundleIdentifier']) + unregister(source / 'Contents/PlugIns' / legacy['pluginName']) + backup.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(backup)) + self.moved.append((legacy, source, backup)) + + def rollback(self, register): + for legacy, source, backup in reversed(self.moved): + if source.exists(): + raise RuntimeError('Refusing to overwrite a new APP during rollback: ' + str(source)) + self.verifier(backup, legacy['bundleIdentifier']) + shutil.move(str(backup), str(source)) + register(source / 'Contents/PlugIns' / legacy['pluginName']) + self.moved.clear() + + def cleanup(self): + removed = [] + for legacy, source, backup in self.moved: + self.verifier(backup, legacy['bundleIdentifier']) + shutil.rmtree(backup) + removed.append(str(backup)) + self.moved.clear() + return removed diff --git a/tests/identity_migration_tests.py b/tests/identity_migration_tests.py new file mode 100644 index 0000000..808ca34 --- /dev/null +++ b/tests/identity_migration_tests.py @@ -0,0 +1,91 @@ +import importlib.util, json, pathlib, plistlib, subprocess, tempfile, unittest +from unittest.mock import patch + +ROOT = pathlib.Path(__file__).resolve().parent.parent +spec = importlib.util.spec_from_file_location('product_identity', ROOT / 'scripts/product_identity.py') +identity = importlib.util.module_from_spec(spec) +spec.loader.exec_module(identity) + +class IdentityMigrationTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='beautygirl-identity-test.') + self.volume = pathlib.Path(self.tmp.name) + self.legacy = identity.PRODUCT['legacyProducts'][0] + self.old = self.volume / pathlib.Path(self.legacy['installationPath']).relative_to('/') + (self.old / 'Contents/PlugIns' / self.legacy['pluginName'] / 'Contents').mkdir(parents=True) + (self.old / 'Contents/Info.plist').write_bytes(plistlib.dumps({'CFBundleIdentifier': self.legacy['bundleIdentifier']})) + (self.old / 'Contents/PlugIns' / self.legacy['pluginName'] / 'Contents/Info.plist').write_bytes(plistlib.dumps({'ProPlugPlugInList': [{'uuid': identity.PRODUCT['filterUUID']}]})) + self.signature = patch.object(identity.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, '', 'TeamIdentifier=B3W25FX5H5\n')) + self.signature.start() + self.migration = identity.LegacyMigration(self.volume / 'backups', volume=self.volume) + + def tearDown(self): + self.signature.stop() + self.tmp.cleanup() + + def test_success_removes_only_validated_old_product(self): + original = (self.old / 'Contents/Info.plist').read_bytes() + unrelated = self.old.parent / 'Unrelated.app' + unrelated.mkdir() + calls = [] + self.migration.relocate(calls.append) + self.assertFalse(self.old.exists()) + self.assertEqual(len(calls), 1) + self.assertEqual((self.migration.moved[0][2] / 'Contents/Info.plist').read_bytes(), original) + self.assertEqual(len(self.migration.cleanup()), 1) + self.assertTrue(unrelated.exists()) + + def test_failed_new_install_restores_old_bytes_and_registration(self): + original = (self.old / 'Contents/Info.plist').read_bytes() + self.migration.relocate(lambda p: None) + calls = [] + self.migration.rollback(calls.append) + self.assertEqual((self.old / 'Contents/Info.plist').read_bytes(), original) + self.assertEqual(calls, [self.old / 'Contents/PlugIns' / self.legacy['pluginName']]) + + def test_wrong_bundle_id_is_never_moved(self): + (self.old / 'Contents/Info.plist').write_bytes(plistlib.dumps({'CFBundleIdentifier': 'com.unrelated.product'})) + with self.assertRaisesRegex(RuntimeError, 'identity mismatch'): + self.migration.relocate(lambda p: self.fail('Must not deregister another product')) + self.assertTrue(self.old.exists()) + + def test_wrong_filter_uuid_is_never_moved(self): + (self.old / 'Contents/PlugIns' / self.legacy['pluginName'] / 'Contents/Info.plist').write_bytes(plistlib.dumps({'ProPlugPlugInList': []})) + with self.assertRaisesRegex(RuntimeError, 'filter UUID'): + self.migration.relocate(lambda p: self.fail('Must not deregister another filter')) + self.assertTrue(self.old.exists()) + + def test_invalid_signature_keeps_original(self): + with patch.object(identity.subprocess, 'run', side_effect=subprocess.CalledProcessError(1, 'codesign')): + with self.assertRaises(subprocess.CalledProcessError): + self.migration.relocate(lambda p: self.fail('Unverified APP must remain')) + self.assertTrue(self.old.exists()) + + def test_existing_backup_cannot_be_overwritten(self): + backup = self.volume / 'backups' / ('legacy-' + self.legacy['name'] + '.app') + backup.mkdir(parents=True) + with self.assertRaisesRegex(RuntimeError, 'already exists'): + self.migration.relocate(lambda p: self.fail('Recovery copy must be preserved')) + self.assertTrue(self.old.exists()) + + def test_failed_deregistration_keeps_old_installation(self): + def unavailable(path): + raise RuntimeError('registry unavailable') + with self.assertRaisesRegex(RuntimeError, 'registry unavailable'): + self.migration.relocate(unavailable) + self.assertTrue(self.old.exists()) + + def test_project_and_legacy_template_share_stable_filter_identity(self): + p = identity.PRODUCT + pbx = (ROOT / p['xcodeProject'] / 'project.pbxproj').read_text() + self.assertEqual(pbx.count('PRODUCT_BUNDLE_IDENTIFIER = ' + p['bundleIdentifier'] + ';'), 2) + self.assertEqual(pbx.count('PRODUCT_BUNDLE_IDENTIFIER = ' + p['pluginBundleIdentifier'] + ';'), 2) + self.assertNotIn('beatygril', pbx) + plugin = plistlib.loads((ROOT / p['sourceDirectory'] / 'Plugin/Info.plist').read_bytes()) + self.assertEqual(plugin['ProPlugPlugInList'][0]['className'], p['pluginClass']) + self.assertEqual(plugin['ProPlugPlugInList'][0]['uuid'], p['filterUUID']) + self.assertEqual(plugin['CFBundleShortVersionString'], p['version']) + self.assertIn('pluginUUID="' + p['filterUUID'] + '"', (ROOT / 'Templates/大鱼人像美化/大鱼人像美化.moef').read_text()) + +if __name__ == '__main__': + unittest.main()