| 1 | #!/usr/bin/env python3 |
| 2 | """Build the local macOS canvas bundle, or with --snow-leopard the Mac OS X 10.6 one.""" |
| 3 | import argparse |
| 4 | from datetime import datetime |
| 5 | import json |
| 6 | import os |
| 7 | from pathlib import Path |
| 8 | import platform |
| 9 | import plistlib |
| 10 | import re |
| 11 | import shutil |
| 12 | import subprocess |
| 13 | import tempfile |
| 14 | |
| 15 | parser = argparse.ArgumentParser(description=__doc__) |
| 16 | parser.add_argument('--release', action='store_true') |
| 17 | parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path') |
| 18 | parser.add_argument('--dsym', type=Path, help="Move the executable's debug info into a dSYM at this path") |
| 19 | parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise") |
| 20 | host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64' |
| 21 | parser.add_argument('--arch', choices=['aarch64', 'x86_64'], default=host, help="The app's architecture; this Mac's by default") |
| 22 | parser.add_argument('--snow-leopard', action='store_true', |
| 23 | help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard') |
| 24 | parser.add_argument('--sign-identity', metavar='SHA1', |
| 25 | help="A Developer ID Application certificate's SHA-1 hash; found in the keychain otherwise") |
| 26 | parser.add_argument('--profile', type=Path, |
| 27 | help='A Developer ID provisioning profile for the app; found where Xcode keeps them otherwise') |
| 28 | parser.add_argument('--sign', choices=['developer-id', 'ad-hoc'], |
| 29 | help='Require Developer ID with the iCloud container, or sign ad hoc; Developer ID where available otherwise') |
| 30 | args = parser.parse_args() |
| 31 | if args.bundle_id and not args.output: |
| 32 | parser.error('Use --bundle-id with --output.') |
| 33 | if args.sign and args.snow_leopard: |
| 34 | parser.error('The 10.6 bundle stays unsigned.') |
| 35 | # The oldest macOS each build runs on: 10.6's own build, wgpu's floor on Intel, and Apple |
| 36 | # silicon's first, which rustc also defaults to. |
| 37 | minimum = '10.6' if args.snow_leopard else {'x86_64': '10.13', 'aarch64': '11.0'}[args.arch] |
| 38 | if args.output and (args.output.suffix != '.app' or args.output.exists()): |
| 39 | parser.error('Choose a new output path ending in .app.') |
| 40 | root = Path(__file__).resolve().parents[2] |
| 41 | # The Apple Developer team and what is registered under it. With the team's Developer ID |
| 42 | # Application identity and a Developer ID profile for BUNDLE_ID naming CONTAINER, the app is |
| 43 | # signed with hardened runtime and the iCloud container (iCloud notebooks, NSUbiquitousContainers); |
| 44 | # without them, ad hoc, and iCloud Drive works through folders the user picks. |
| 45 | TEAM = '9R7DPNW28H' |
| 46 | BUNDLE_ID = 'net.paperclover.snowbound' |
| 47 | CONTAINER = 'iCloud.net.paperclover.snowbound' |
| 48 | # OneNote's sections, tables of contents and packages, which no Mac app declares; OneNote may open them too. |
| 49 | ONENOTE_TYPES = [('com.microsoft.onenote.section', 'OneNote Section', 'one'), |
| 50 | ('com.microsoft.onenote.table-of-contents', 'OneNote Table of Contents', 'onetoc2'), |
| 51 | ('com.microsoft.onenote.package', 'OneNote Package', 'onepkg')] |
| 52 | |
| 53 | |
| 54 | def developer_id(): |
| 55 | """The team's Developer ID Application identity by SHA-1 hash. The listing names the |
| 56 | certificate's holder, so none of it is printed.""" |
| 57 | listing = subprocess.run(['security', 'find-identity', '-v', '-p', 'codesigning'], |
| 58 | capture_output=True, text=True).stdout |
| 59 | for line in listing.splitlines(): |
| 60 | if 'Developer ID Application:' in line and f'({TEAM})' in line: |
| 61 | found = re.search(r'\b[0-9A-F]{40}\b', line) |
| 62 | if found: |
| 63 | return found.group(0) |
| 64 | return None |
| 65 | |
| 66 | |
| 67 | def profile_entitlements(path): |
| 68 | decoded = subprocess.run(['security', 'cms', '-D', '-i', str(path)], capture_output=True) |
| 69 | if decoded.returncode != 0: |
| 70 | return None |
| 71 | profile = plistlib.loads(decoded.stdout) |
| 72 | entitlements = profile.get('Entitlements', {}) |
| 73 | fits = (entitlements.get('com.apple.application-identifier') == f'{TEAM}.{BUNDLE_ID}' |
| 74 | and CONTAINER in entitlements.get('com.apple.developer.icloud-container-identifiers', []) |
| 75 | and 'ProvisionedDevices' not in profile |
| 76 | and profile.get('ExpirationDate', datetime.max) > datetime.now()) |
| 77 | return entitlements if fits else None |
| 78 | |
| 79 | |
| 80 | def developer_id_profile(): |
| 81 | """A Developer ID provisioning profile for the app and its iCloud container.""" |
| 82 | folders = [Path.home() / 'Library/Developer/Xcode/UserData/Provisioning Profiles', |
| 83 | Path.home() / 'Library/MobileDevice/Provisioning Profiles'] |
| 84 | for path in sorted(path for folder in folders if folder.is_dir() |
| 85 | for path in folder.glob('*.provisionprofile')): |
| 86 | if profile_entitlements(path): |
| 87 | return path |
| 88 | return None |
| 89 | profile = ['--release'] if args.release else [] |
| 90 | if args.snow_leopard: |
| 91 | subprocess.run([root / 'platform/snow-leopard/cargo.sh', 'build', '-p', 'snowbound', '--no-default-features'] + profile, |
| 92 | cwd=root, check=True) |
| 93 | target = root / 'target/snow-leopard' |
| 94 | built = target / 'x86_64-apple-macosx10.6' |
| 95 | else: |
| 96 | metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--format-version=1', '--no-deps'], cwd=root)) |
| 97 | target = built = Path(metadata['target_directory']) |
| 98 | cross = [] if args.arch == host else ['--target', f'{args.arch}-apple-darwin'] |
| 99 | if cross: |
| 100 | built = target / cross[1] |
| 101 | subprocess.run(['cargo', 'build', '-p', 'snowbound'] + profile + cross, cwd=root, check=True, |
| 102 | env={**os.environ, 'MACOSX_DEPLOYMENT_TARGET': minimum} if args.arch == 'x86_64' else None) |
| 103 | bundle = args.output.resolve() if args.output else target / 'Snowbound.app' |
| 104 | if args.output: |
| 105 | bundle.mkdir(parents=True, exist_ok=False) |
| 106 | binary = bundle / 'Contents/MacOS/Snowbound' |
| 107 | binary.parent.mkdir(parents=True, exist_ok=True) |
| 108 | pending = binary.with_suffix('.next') |
| 109 | shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending) |
| 110 | pending.replace(binary) |
| 111 | if args.dsym: |
| 112 | subprocess.run(['dsymutil', binary, '-o', args.dsym], check=True) |
| 113 | subprocess.run(['strip', '-S', binary], check=True) |
| 114 | icons = root / 'crates/snowbound/assets/icon' |
| 115 | resources = bundle / 'Contents/Resources' |
| 116 | resources.mkdir(exist_ok=True) |
| 117 | if args.snow_leopard: |
| 118 | # 10.6 reads 256 and 512 pixel entries (ic08, ic09), which the checked-in icns lacks. |
| 119 | with tempfile.TemporaryDirectory() as scratch: |
| 120 | iconset = Path(scratch) / 'Snowbound.iconset' |
| 121 | iconset.mkdir() |
| 122 | for side in [16, 32, 128, 256, 512]: |
| 123 | subprocess.run(['sips', '-z', str(side), str(side), icons / 'Snowbound-SnowLeopard.png', |
| 124 | '--out', iconset / f'icon_{side}x{side}.png'], check=True, capture_output=True) |
| 125 | subprocess.run(['iconutil', '-c', 'icns', iconset, '-o', resources / 'Snowbound.icns'], check=True) |
| 126 | else: |
| 127 | # macOS 26 draws the Liquid Glass icon from the catalog; it holds no flattened renditions |
| 128 | # (see the icon folder's README), so earlier versions fall back to the icns. |
| 129 | shutil.copy2(icons / 'Snowbound-Sequoia.icns', resources / 'Snowbound.icns') |
| 130 | shutil.copy2(icons / 'Assets.car', resources) |
| 131 | # tools/release.py names the build, as 2026-09-29-r10, which About shows. |
| 132 | build = os.environ.get('SNOWBOUND_BUILD') |
| 133 | versions = {} |
| 134 | if build: |
| 135 | date, revision = build.split('-r') |
| 136 | versions = {'CFBundleShortVersionString': f'{date} revision {revision}', |
| 137 | 'CFBundleVersion': f'{date.replace("-", "")}.{revision}'} |
| 138 | (bundle / 'Contents/Info.plist').write_bytes(plistlib.dumps({ |
| 139 | 'CFBundleExecutable': binary.name, |
| 140 | 'CFBundleIdentifier': args.bundle_id or BUNDLE_ID, |
| 141 | 'CFBundleName': bundle.stem, |
| 142 | 'CFBundleDisplayName': bundle.stem, |
| 143 | 'CFBundlePackageType': 'APPL', |
| 144 | 'CFBundleVersion': '1', |
| 145 | 'NSHighResolutionCapable': True, |
| 146 | 'NSPrincipalClass': 'NSApplication', |
| 147 | 'CFBundleIconFile': 'Snowbound', |
| 148 | 'CFBundleIconName': 'Snowbound-Tahoe', |
| 149 | 'NSLocalNetworkUsageDescription': 'Snowbound connects to file servers and other Snowbounds on your network to open and sync shared notebooks.', |
| 150 | # Live presence and Live Share find other Snowbounds by Bonjour. |
| 151 | 'NSBonjourServices': ['_snowbound._tcp'], |
| 152 | 'NSMicrophoneUsageDescription':'Snowbound records audio into your notes when you choose Record Audio or Record Video.', |
| 153 | 'NSCameraUsageDescription': 'Snowbound records video into your notes when you choose Record Video.', |
| 154 | 'NSUbiquitousContainers': {CONTAINER: { |
| 155 | 'NSUbiquitousContainerIsDocumentScopePublic': True, |
| 156 | 'NSUbiquitousContainerName': 'Snowbound', |
| 157 | 'NSUbiquitousContainerSupportedFolderLevels': 'Any', |
| 158 | }}, |
| 159 | 'LSMinimumSystemVersion': minimum, |
| 160 | # Live Share's links: snowbound://join/<code>, as snowbound.paperclover.net's pages open. |
| 161 | 'CFBundleURLTypes': [{'CFBundleURLName': 'Snowbound Live Share', 'CFBundleURLSchemes': ['snowbound']}], |
| 162 | 'CFBundleDocumentTypes': [{ |
| 163 | 'CFBundleTypeName': 'OneNote Notebook', |
| 164 | 'CFBundleTypeRole': 'Editor', |
| 165 | 'LSHandlerRank': 'Alternate', |
| 166 | 'LSItemContentTypes': [identifier for identifier, _, _ in ONENOTE_TYPES], |
| 167 | 'CFBundleTypeIconSystemGenerated': 1, |
| 168 | }], |
| 169 | 'UTImportedTypeDeclarations': [{ |
| 170 | 'UTTypeIdentifier': identifier, |
| 171 | 'UTTypeDescription': description, |
| 172 | 'UTTypeConformsTo': ['public.data'], |
| 173 | 'UTTypeTagSpecification': {'public.filename-extension': [extension], |
| 174 | 'public.mime-type': 'application/onenote'}, |
| 175 | } for identifier, description, extension in ONENOTE_TYPES], |
| 176 | } | versions)) |
| 177 | # 10.6 runs the bundle unsigned. |
| 178 | if not args.snow_leopard: |
| 179 | identity = args.sign != 'ad-hoc' and (args.sign_identity or developer_id()) |
| 180 | profile = args.sign != 'ad-hoc' and (args.profile or developer_id_profile()) |
| 181 | if identity and profile and (args.bundle_id or BUNDLE_ID) == BUNDLE_ID: |
| 182 | shutil.copy2(profile, bundle / 'Contents/embedded.provisionprofile') |
| 183 | with tempfile.TemporaryDirectory() as scratch: |
| 184 | entitlements = Path(scratch) / 'entitlements.plist' |
| 185 | entitlements.write_bytes(plistlib.dumps({ |
| 186 | 'com.apple.application-identifier': f'{TEAM}.{BUNDLE_ID}', |
| 187 | 'com.apple.developer.team-identifier': TEAM, |
| 188 | 'com.apple.developer.icloud-services': ['CloudDocuments'], |
| 189 | 'com.apple.developer.icloud-container-identifiers': [CONTAINER], |
| 190 | 'com.apple.developer.ubiquity-container-identifiers': [CONTAINER], |
| 191 | 'com.apple.developer.icloud-container-environment': 'Production', |
| 192 | # Hardened runtime's Record Audio and Record Video. |
| 193 | 'com.apple.security.device.audio-input': True, |
| 194 | 'com.apple.security.device.camera': True, |
| 195 | })) |
| 196 | # A release fails where the timestamp server can't be reached, as notarization needs it. |
| 197 | timestamp = ['--timestamp'] if args.sign == 'developer-id' else [] |
| 198 | subprocess.run(['codesign', '--force', '--options', 'runtime', *timestamp, '--entitlements', entitlements, |
| 199 | '--sign', identity, str(bundle)], check=True) |
| 200 | print(f'Signed with the Developer ID of team {TEAM}, with the iCloud container {CONTAINER}.') |
| 201 | elif args.sign == 'developer-id': |
| 202 | raise SystemExit(f'No Developer ID Application identity of team {TEAM} and profile for {BUNDLE_ID} with {CONTAINER}.') |
| 203 | else: |
| 204 | subprocess.run(['codesign', '--force', '--sign', '-', str(bundle)], check=True) |
| 205 | subprocess.run(['codesign', '--verify', '--strict', str(bundle)], check=True) |
| 206 | print(bundle) |