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 tempfile
14
15parser = argparse.ArgumentParser(description=__doc__)
16parser.add_argument('--release', action='store_true')
17parser.add_argument('--output', type=Path, help='Create a separate bundle at a new .app path')
18parser.add_argument('--dsym', type=Path, help="Move the executable's debug info into a dSYM at this path")
19parser.add_argument('--bundle-id', help="Bundle identifier for the separate app; the app's own otherwise")
20host = 'aarch64' if platform.machine() == 'arm64' else 'x86_64'
21parser.add_argument('--arch', choices=['aarch64', 'x86_64'], default=host, help="The app's architecture; this Mac's by default")
22parser.add_argument('--snow-leopard', action='store_true',
23 help='Build with platform/snow-leopard/cargo.sh into target/snow-leopard')
24parser.add_argument('--sign-identity', metavar='SHA1',
25 help="A Developer ID Application certificate's SHA-1 hash; found in the keychain otherwise")
26parser.add_argument('--profile', type=Path,
27 help='A Developer ID provisioning profile for the app; found where Xcode keeps them otherwise')
28parser.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')
30args = parser.parse_args()
31if args.bundle_id and not args.output:
32 parser.error('Use --bundle-id with --output.')
33if 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.
37minimum = '10.6' if args.snow_leopard else {'x86_64': '10.13', 'aarch64': '11.0'}[args.arch]
38if args.output and (args.output.suffix != '.app' or args.output.exists()):
39 parser.error('Choose a new output path ending in .app.')
40root = 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.
45TEAM = '9R7DPNW28H'
46BUNDLE_ID = 'net.paperclover.snowbound'
47CONTAINER = 'iCloud.net.paperclover.snowbound'
48# OneNote's sections, tables of contents and packages, which no Mac app declares; OneNote may open them too.
49ONENOTE_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
54def 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
67def 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
80def 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
89profile = ['--release'] if args.release else []
90if 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'
95else:
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)
103bundle = args.output.resolve() if args.output else target / 'Snowbound.app'
104if args.output:
105 bundle.mkdir(parents=True, exist_ok=False)
106binary = bundle / 'Contents/MacOS/Snowbound'
107binary.parent.mkdir(parents=True, exist_ok=True)
108pending = binary.with_suffix('.next')
109shutil.copy2(built / ('release' if args.release else 'debug') / 'snowbound', pending)
110pending.replace(binary)
111if args.dsym:
112 subprocess.run(['dsymutil', binary, '-o', args.dsym], check=True)
113 subprocess.run(['strip', '-S', binary], check=True)
114icons = root / 'crates/snowbound/assets/icon'
115resources = bundle / 'Contents/Resources'
116resources.mkdir(exist_ok=True)
117if 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)
126else:
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.
132build = os.environ.get('SNOWBOUND_BUILD')
133versions = {}
134if 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.
178if 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)
206print(bundle)