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