"""Shared Sparkle bundle configuration and inside-out signing (macOS only).""" import argparse import base64 import json from pathlib import Path import plistlib import re import shutil import subprocess import tempfile from urllib.parse import urlsplit def read_configuration(path): config = json.loads(Path(path).read_text()) url = urlsplit(config['feed_url']) if url.scheme != 'https' or not url.hostname or url.username or url.password or url.fragment: raise ValueError('feed_url must be an HTTPS URL without credentials or fragment') if len(base64.b64decode(config['public_ed_key'], validate=True)) != 32: raise ValueError('public_ed_key must encode an Ed25519 public key') if not re.fullmatch(r'[A-Za-z0-9.-]+', config['bundle_id']): raise ValueError('invalid bundle_id') config.setdefault('install', 'prompt') if config['install'] not in INSTALL_MODES: raise ValueError('install must be "prompt" or "automatic"') return config # "prompt" is Sparkle's standard flow: it asks before checking automatically and # every install is confirmed. "automatic" checks daily, downloads in the background # and installs on quit without asking first; pair it with NativeUpdater(policy: # .quiet) so that nothing it finds opens a window. These are Info.plist defaults: # a person's own choice, stored in their preferences, still wins. INSTALL_MODES = { 'prompt': dict(SUAllowsAutomaticUpdates=False), 'automatic': dict(SUAllowsAutomaticUpdates=True, SUEnableAutomaticChecks=True, SUAutomaticallyUpdate=True, SUScheduledCheckInterval=86400), } SPARKLE_KEYS = ['SUFeedURL', 'SUPublicEDKey', 'SUEnableAutomaticChecks', 'SUAutomaticallyUpdate', 'SUAllowsAutomaticUpdates', 'SUVerifyUpdateBeforeExtraction', 'SUScheduledCheckInterval'] def install_keys(config): return INSTALL_MODES[config['install']] def configure(app, configuration=None, version=None, build=None): info_path = app / 'Contents/Info.plist' info = plistlib.loads(info_path.read_bytes()) if version is not None: if not re.fullmatch(r'[0-9]+(?:\.[0-9]+){0,2}', version): raise ValueError('version must be a numeric marketing version') info['CFBundleShortVersionString'] = version if build is not None: if not re.fullmatch(r'[1-9][0-9]*', build): raise ValueError('build must be a positive increasing integer') info['CFBundleVersion'] = build for key in SPARKLE_KEYS: info.pop(key, None) if configuration: config = read_configuration(configuration) if config['bundle_id'] != info['CFBundleIdentifier']: raise ValueError('update configuration belongs to another app') info.update(SUFeedURL=config['feed_url'], SUPublicEDKey=config['public_ed_key'], SUVerifyUpdateBeforeExtraction=True, **install_keys(config)) # In "prompt" mode SUEnableAutomaticChecks stays unset: Sparkle asks and preserves # the user's choice, and installs need the normal explicit relaunch prompt. # Either way the app's quit handling (editor guards, leaving a call) still runs. info_path.write_bytes(plistlib.dumps(info)) def embed_sparkle(app, package): frameworks = list((package / '.build/artifacts').rglob('Sparkle.framework')) frameworks = [p for p in frameworks if p.is_dir() and not p.is_symlink()] if not frameworks: return False if len(frameworks) != 1: raise ValueError('ambiguous Sparkle framework artifact') destination = app / 'Contents/Frameworks/Sparkle.framework' destination.parent.mkdir(parents=True, exist_ok=True) if destination.exists(): shutil.rmtree(destination) shutil.copytree(frameworks[0], destination, symlinks=True) info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) executable = app / 'Contents/MacOS' / info['CFBundleExecutable'] loads = subprocess.check_output(['otool', '-l', str(executable)], text=True) if 'path @executable_path/../Frameworks ' not in loads: subprocess.run(['install_name_tool', '-add_rpath', '@executable_path/../Frameworks', str(executable)], check=True) return True def sign_bundle(app, identity='-'): command = ['codesign', '--force', '--sign', identity] if identity != '-': command += ['--options', 'runtime', '--timestamp'] frameworks = app / 'Contents/Frameworks' # Files and nested bundles must be signed before their containing framework. sparkle = frameworks / 'Sparkle.framework' if sparkle.exists(): current = sparkle / 'Versions/Current' for relative in ['XPCServices/Downloader.xpc', 'XPCServices/Installer.xpc', 'Autoupdate', 'Updater.app']: target = current / relative if not target.exists(): raise ValueError(f'Sparkle helper missing: {relative}') subprocess.run(command + ['--preserve-metadata=entitlements', str(target)], check=True) if frameworks.exists(): for framework in sorted(frameworks.glob('*.framework')): subprocess.run(command + [str(framework)], check=True) info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) entitlements = {} if info.get('NSCameraUsageDescription'): entitlements['com.apple.security.device.camera'] = True if info.get('NSMicrophoneUsageDescription'): entitlements['com.apple.security.device.audio-input'] = True # Keep device grants on the host app only, including when release preparation # re-signs an existing development bundle with the hardened runtime enabled. with tempfile.NamedTemporaryFile(suffix='.plist') as file: file.write(plistlib.dumps(entitlements)) file.flush() subprocess.run(command + ['--entitlements', file.name, str(app)], check=True) subprocess.run(['codesign', '--verify', '--deep', '--strict', str(app)], check=True) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('app', type=Path) parser.add_argument('--package', type=Path, required=True) parser.add_argument('--updates-config', type=Path) parser.add_argument('--version') parser.add_argument('--build') parser.add_argument('--sign-identity', default='-') args = parser.parse_args() configure(args.app, args.updates_config, args.version, args.build) embedded = embed_sparkle(args.app, args.package) if args.updates_config and not embedded: raise ValueError('configured updater requires an embedded Sparkle framework') sign_bundle(args.app, args.sign_identity) if __name__ == '__main__': main()