rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1"""Shared Sparkle bundle configuration and inside-out signing (macOS only)."""
2import argparse
3import base64
4import json
5from pathlib import Path
6import plistlib
7import re
8import shutil
9import subprocess
10import tempfile
11from urllib.parse import urlsplit
12
13
14def 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.
34INSTALL_MODES = {
35 'prompt': dict(SUAllowsAutomaticUpdates=False),
36 'automatic': dict(SUAllowsAutomaticUpdates=True, SUEnableAutomaticChecks=True,
37 SUAutomaticallyUpdate=True, SUScheduledCheckInterval=86400),
38}
39SPARKLE_KEYS = ['SUFeedURL', 'SUPublicEDKey', 'SUEnableAutomaticChecks', 'SUAutomaticallyUpdate',
40 'SUAllowsAutomaticUpdates', 'SUVerifyUpdateBeforeExtraction', 'SUScheduledCheckInterval']
41
42
43def install_keys(config):
44 return INSTALL_MODES[config['install']]
45
46
47def 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
72def 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
92def 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
124def 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
140if __name__ == '__main__':
141 main()
142