| 1 | """Shared Sparkle bundle configuration and inside-out signing (macOS only).""" |
| 2 | import argparse |
| 3 | import base64 |
| 4 | import json |
| 5 | from pathlib import Path |
| 6 | import plistlib |
| 7 | import re |
| 8 | import shutil |
| 9 | import subprocess |
| 10 | import tempfile |
| 11 | from urllib.parse import urlsplit |
| 12 | |
| 13 | |
| 14 | def read_configuration(path): |
| 15 | config = json.loads(Path(path).read_text()) |
| 16 | url = urlsplit(config['feed_url']) |
| 17 | if url.scheme != 'https' or not url.hostname or url.username or url.password or url.fragment: |
| 18 | raise ValueError('feed_url must be an HTTPS URL without credentials or fragment') |
| 19 | if len(base64.b64decode(config['public_ed_key'], validate=True)) != 32: |
| 20 | raise ValueError('public_ed_key must encode an Ed25519 public key') |
| 21 | if not re.fullmatch(r'[A-Za-z0-9.-]+', config['bundle_id']): |
| 22 | raise ValueError('invalid bundle_id') |
| 23 | config.setdefault('install', 'prompt') |
| 24 | if config['install'] not in INSTALL_MODES: |
| 25 | raise ValueError('install must be "prompt" or "automatic"') |
| 26 | return config |
| 27 | |
| 28 | |
| 29 | # "prompt" is Sparkle's standard flow: it asks before checking automatically and |
| 30 | # every install is confirmed. "automatic" checks daily, downloads in the background |
| 31 | # and installs on quit without asking first; pair it with NativeUpdater(policy: |
| 32 | # .quiet) so that nothing it finds opens a window. These are Info.plist defaults: |
| 33 | # a person's own choice, stored in their preferences, still wins. |
| 34 | INSTALL_MODES = { |
| 35 | 'prompt': dict(SUAllowsAutomaticUpdates=False), |
| 36 | 'automatic': dict(SUAllowsAutomaticUpdates=True, SUEnableAutomaticChecks=True, |
| 37 | SUAutomaticallyUpdate=True, SUScheduledCheckInterval=86400), |
| 38 | } |
| 39 | SPARKLE_KEYS = ['SUFeedURL', 'SUPublicEDKey', 'SUEnableAutomaticChecks', 'SUAutomaticallyUpdate', |
| 40 | 'SUAllowsAutomaticUpdates', 'SUVerifyUpdateBeforeExtraction', 'SUScheduledCheckInterval'] |
| 41 | |
| 42 | |
| 43 | def install_keys(config): |
| 44 | return INSTALL_MODES[config['install']] |
| 45 | |
| 46 | |
| 47 | def configure(app, configuration=None, version=None, build=None): |
| 48 | info_path = app / 'Contents/Info.plist' |
| 49 | info = plistlib.loads(info_path.read_bytes()) |
| 50 | if version is not None: |
| 51 | if not re.fullmatch(r'[0-9]+(?:\.[0-9]+){0,2}', version): |
| 52 | raise ValueError('version must be a numeric marketing version') |
| 53 | info['CFBundleShortVersionString'] = version |
| 54 | if build is not None: |
| 55 | if not re.fullmatch(r'[1-9][0-9]*', build): |
| 56 | raise ValueError('build must be a positive increasing integer') |
| 57 | info['CFBundleVersion'] = build |
| 58 | for key in SPARKLE_KEYS: |
| 59 | info.pop(key, None) |
| 60 | if configuration: |
| 61 | config = read_configuration(configuration) |
| 62 | if config['bundle_id'] != info['CFBundleIdentifier']: |
| 63 | raise ValueError('update configuration belongs to another app') |
| 64 | info.update(SUFeedURL=config['feed_url'], SUPublicEDKey=config['public_ed_key'], |
| 65 | SUVerifyUpdateBeforeExtraction=True, **install_keys(config)) |
| 66 | # In "prompt" mode SUEnableAutomaticChecks stays unset: Sparkle asks and preserves |
| 67 | # the user's choice, and installs need the normal explicit relaunch prompt. |
| 68 | # Either way the app's quit handling (editor guards, leaving a call) still runs. |
| 69 | info_path.write_bytes(plistlib.dumps(info)) |
| 70 | |
| 71 | |
| 72 | def embed_sparkle(app, package): |
| 73 | frameworks = list((package / '.build/artifacts').rglob('Sparkle.framework')) |
| 74 | frameworks = [p for p in frameworks if p.is_dir() and not p.is_symlink()] |
| 75 | if not frameworks: |
| 76 | return False |
| 77 | if len(frameworks) != 1: |
| 78 | raise ValueError('ambiguous Sparkle framework artifact') |
| 79 | destination = app / 'Contents/Frameworks/Sparkle.framework' |
| 80 | destination.parent.mkdir(parents=True, exist_ok=True) |
| 81 | if destination.exists(): |
| 82 | shutil.rmtree(destination) |
| 83 | shutil.copytree(frameworks[0], destination, symlinks=True) |
| 84 | info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) |
| 85 | executable = app / 'Contents/MacOS' / info['CFBundleExecutable'] |
| 86 | loads = subprocess.check_output(['otool', '-l', str(executable)], text=True) |
| 87 | if 'path @executable_path/../Frameworks ' not in loads: |
| 88 | subprocess.run(['install_name_tool', '-add_rpath', '@executable_path/../Frameworks', str(executable)], check=True) |
| 89 | return True |
| 90 | |
| 91 | |
| 92 | def sign_bundle(app, identity='-'): |
| 93 | command = ['codesign', '--force', '--sign', identity] |
| 94 | if identity != '-': |
| 95 | command += ['--options', 'runtime', '--timestamp'] |
| 96 | frameworks = app / 'Contents/Frameworks' |
| 97 | # Files and nested bundles must be signed before their containing framework. |
| 98 | sparkle = frameworks / 'Sparkle.framework' |
| 99 | if sparkle.exists(): |
| 100 | current = sparkle / 'Versions/Current' |
| 101 | for relative in ['XPCServices/Downloader.xpc', 'XPCServices/Installer.xpc', 'Autoupdate', 'Updater.app']: |
| 102 | target = current / relative |
| 103 | if not target.exists(): |
| 104 | raise ValueError(f'Sparkle helper missing: {relative}') |
| 105 | subprocess.run(command + ['--preserve-metadata=entitlements', str(target)], check=True) |
| 106 | if frameworks.exists(): |
| 107 | for framework in sorted(frameworks.glob('*.framework')): |
| 108 | subprocess.run(command + [str(framework)], check=True) |
| 109 | info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) |
| 110 | entitlements = {} |
| 111 | if info.get('NSCameraUsageDescription'): |
| 112 | entitlements['com.apple.security.device.camera'] = True |
| 113 | if info.get('NSMicrophoneUsageDescription'): |
| 114 | entitlements['com.apple.security.device.audio-input'] = True |
| 115 | # Keep device grants on the host app only, including when release preparation |
| 116 | # re-signs an existing development bundle with the hardened runtime enabled. |
| 117 | with tempfile.NamedTemporaryFile(suffix='.plist') as file: |
| 118 | file.write(plistlib.dumps(entitlements)) |
| 119 | file.flush() |
| 120 | subprocess.run(command + ['--entitlements', file.name, str(app)], check=True) |
| 121 | subprocess.run(['codesign', '--verify', '--deep', '--strict', str(app)], check=True) |
| 122 | |
| 123 | |
| 124 | def main(): |
| 125 | parser = argparse.ArgumentParser(description=__doc__) |
| 126 | parser.add_argument('app', type=Path) |
| 127 | parser.add_argument('--package', type=Path, required=True) |
| 128 | parser.add_argument('--updates-config', type=Path) |
| 129 | parser.add_argument('--version') |
| 130 | parser.add_argument('--build') |
| 131 | parser.add_argument('--sign-identity', default='-') |
| 132 | args = parser.parse_args() |
| 133 | configure(args.app, args.updates_config, args.version, args.build) |
| 134 | embedded = embed_sparkle(args.app, args.package) |
| 135 | if args.updates_config and not embedded: |
| 136 | raise ValueError('configured updater requires an embedded Sparkle framework') |
| 137 | sign_bundle(args.app, args.sign_identity) |
| 138 | |
| 139 | |
| 140 | if __name__ == '__main__': |
| 141 | main() |
| 142 | |