import argparse
import json
import os
from pathlib import Path
import plistlib
import re
import shutil
import subprocess
import sys
import tomllib
def run(args, *, cwd=None, env=None):
print('+', ' '.join(map(str, args)), flush=True)
subprocess.run(list(map(str, args)), cwd=cwd, env=env, check=True)
def output(args):
return subprocess.check_output(args, text=True).strip()
def write(path, content):
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists() or path.read_text() != content:
path.write_text(content)
def rust_bridge(entry, application_id):
return '''pub struct Application(cranpose::watchos::Application);
impl Application {
pub fn resize(&mut self, width: u32, height: u32, density: f32) -> Result<(), cranpose::watchos::SurfaceError> {
self.0.resize(width, height, density)
}
pub fn tick(&mut self, elapsed: u64) -> bool { self.0.tick(elapsed) }
pub fn pixels(&mut self) -> &[u8] { self.0.pixels() }
pub fn sprites(&mut self) -> &[f32] { self.0.sprites().sprites() }
pub fn upload_count(&self) -> usize { self.0.last_sprites().uploads().len() }
fn upload(&self, index: usize) -> Option<&cranpose::watchos::TextureUpload> { self.0.last_sprites().uploads().get(index) }
pub fn upload_id(&self, index: usize) -> u32 { self.upload(index).map_or(0, |upload| upload.id) }
pub fn upload_width(&self, index: usize) -> u32 { self.upload(index).map_or(0, |upload| upload.width) }
pub fn upload_height(&self, index: usize) -> u32 { self.upload(index).map_or(0, |upload| upload.height) }
pub fn upload_pixels(&self, index: usize) -> &[u8] { self.upload(index).map_or(&[], |upload| upload.pixels.as_slice()) }
pub fn releases(&self) -> &[u32] { self.0.last_sprites().releases() }
pub fn width(&self) -> u32 { self.0.width() }
pub fn height(&self) -> u32 { self.0.height() }
pub fn touch(&mut self, phase: u8, x: f32, y: f32) { self.0.touch(phase, x, y); }
pub fn crown(&mut self, delta: f32, elapsed: u64) -> bool { self.0.crown(delta, elapsed) }
pub fn set_active(&mut self, active: bool) { self.0.set_active(active); }
pub fn set_safe_area(&mut self, left: f32, top: f32, right: f32, bottom: f32) {
self.0.set_safe_area(left, top, right, bottom);
}
}
#[cxx::bridge(namespace = "cranpose_watchos")]
mod ffi {
extern "Rust" {
type Application;
fn create_application(width: u32, height: u32, density: f32) -> Result<Box<Application>>;
fn resize(self: &mut Application, width: u32, height: u32, density: f32) -> Result<()>;
fn tick(self: &mut Application, elapsed_nanos: u64) -> bool;
fn pixels(self: &mut Application) -> &[u8];
fn sprites(self: &mut Application) -> &[f32];
fn upload_count(self: &Application) -> usize;
fn upload_id(self: &Application, index: usize) -> u32;
fn upload_width(self: &Application, index: usize) -> u32;
fn upload_height(self: &Application, index: usize) -> u32;
fn upload_pixels(self: &Application, index: usize) -> &[u8];
fn releases(self: &Application) -> &[u32];
fn width(self: &Application) -> u32;
fn height(self: &Application) -> u32;
fn touch(self: &mut Application, phase: u8, x: f32, y: f32);
fn crown(self: &mut Application, delta: f32, elapsed_millis: u64) -> bool;
fn set_active(self: &mut Application, active: bool);
fn set_safe_area(self: &mut Application, left: f32, top: f32, right: f32, bottom: f32);
}
}
pub fn create_application(width: u32, height: u32, density: f32) -> Result<Box<Application>, Box<dyn std::error::Error>> {
cranpose::host::set_application_id(''' + json.dumps(application_id) + ''')?;
Ok(Box::new(Application(cranpose::watchos::Application::new(width, height, density, application::''' + entry + ''')?)))
}
'''
def compile_icon(icon, root, bundle, sdk_name, deployment_target):
catalog = root / 'Assets.xcassets'
iconset = catalog / 'AppIcon.appiconset'
iconset.mkdir(parents=True, exist_ok=True)
shutil.copyfile(icon, iconset / 'icon.png')
info = {'info': {'author': 'xcode', 'version': 1}}
write(catalog / 'Contents.json', json.dumps(info))
write(iconset / 'Contents.json', json.dumps({'images': [{
'filename': 'icon.png', 'idiom': 'universal', 'platform': 'watchos', 'size': '1024x1024'}], **info}))
partial = root / 'icon-info.plist'
run(['xcrun', 'actool', catalog, '--compile', bundle, '--platform', sdk_name,
'--minimum-deployment-target', deployment_target, '--target-device', 'watch',
'--app-icon', 'AppIcon', '--output-partial-info-plist', partial])
return plistlib.loads(partial.read_bytes())
def prepare_manifest(parser, runner, manifest, app_lock=None):
path = runner / 'Cargo.toml'
previous = tomllib.loads(path.read_text()) if path.exists() else {}
cxx = previous.get('dependencies', {}).get('cxx')
cxx_build = previous.get('build-dependencies', {}).get('cxx-build')
if any(value is not None and not isinstance(value, str) for value in (cxx, cxx_build)):
parser.error('generated bridge dependency configuration was modified')
if cxx:
manifest = manifest.replace('[profile.release]', f'cxx = {json.dumps(cxx)}\n\n[profile.release]')
if cxx_build:
manifest += f'\n[build-dependencies]\ncxx-build = {json.dumps(cxx_build)}\n'
changed = not path.exists() or path.read_text() != manifest
write(path, manifest)
if app_lock and not (runner / 'Cargo.lock').exists():
shutil.copyfile(app_lock, runner / 'Cargo.lock')
if not cxx:
run(['cargo', 'add', 'cxx@1'], cwd=runner)
if not cxx_build:
run(['cargo', 'add', 'cxx-build@1', '--build'], cwd=runner)
if changed and cxx and cxx_build:
run(['cargo', 'update', '--workspace'], cwd=runner)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--manifest', type=Path, required=True)
parser.add_argument('--entry', help='Public composable path relative to the app crate, e.g. ui::OrbitApp')
parser.add_argument('--bundle-id')
parser.add_argument('--name')
parser.add_argument('--companion', help='Bundle id of the iPhone app that carries this watch app; '
'without it the watch app stands alone')
parser.add_argument('--icon', type=Path, help='Square PNG of at least 1024 pixels, relative to the '
'manifest; the watch shows it in a circle')
parser.add_argument('--output', type=Path, required=True)
framework = parser.add_mutually_exclusive_group()
framework.add_argument('--framework-source', type=Path, help='Development-only Cranpose workspace override')
framework.add_argument('--cranpose-version', help='Published Cranpose version with watchOS support')
parser.add_argument('--features', help='Comma-separated application features; defaults stay disabled')
parser.add_argument('--target', choices=['simulator', 'device'], default='simulator')
parser.add_argument('--deployment-target', help='Defaults to 10.0 for simulator, 27.0 for arm64 devices')
parser.add_argument('--sign', help='Device signing identity; provisioning is managed by the caller')
parser.add_argument('--prepare-only', action='store_true')
args = parser.parse_args()
app_manifest = args.manifest.resolve()
app = tomllib.loads(app_manifest.read_text())['package']
metadata = app.get('metadata', {}).get('cranpose', {}).get('watchos', {})
for field in ('entry', 'bundle_id', 'name', 'companion', 'icon', 'features'):
if getattr(args, field) is None:
setattr(args, field, metadata.get(field.replace('_', '-')))
args.name = args.name or app['name']
if args.icon:
args.icon = app_manifest.parent / args.icon
if not args.icon.is_file():
parser.error(f'no icon at {args.icon}')
args.features = args.features or ''
args.deployment_target = args.deployment_target or ('27.0' if args.target == 'device' else '10.0')
extra_info = metadata.get('info-plist', {})
if not isinstance(extra_info, dict):
parser.error('package.metadata.cranpose.watchos.info-plist must be a table')
if not args.entry or not args.bundle_id:
parser.error('entry and bundle-id are required as flags or package.metadata.cranpose.watchos fields')
if not re.fullmatch(r'[A-Za-z_][A-Za-z_0-9]*(::[A-Za-z_][A-Za-z_0-9]*)*', args.entry):
parser.error('--entry must be a Rust function path')
if not re.fullmatch(r'[A-Za-z0-9]+([.-][A-Za-z0-9]+)+', args.bundle_id):
parser.error('invalid bundle identifier')
if args.companion and not args.bundle_id.startswith(args.companion + '.'):
parser.error('the watch bundle id must start with the companion bundle id and a dot')
if not re.fullmatch(r'\d+(\.\d+){1,2}', args.deployment_target):
parser.error('invalid deployment target')
if args.target == 'device' and int(args.deployment_target.split('.')[0]) < 26:
parser.error('arm64 watch devices require watchOS 26 or later')
if args.target == 'device' and int(args.deployment_target.split('.')[0]) < 27:
print(f'warning: the App Store refuses an arm64-only watch app for watchOS {args.deployment_target}; '
'it needs watchOS 27 or an arm64_32 slice', file=sys.stderr)
root = args.output.resolve()
marker = root / '.cranpose-watchos-output'
if root.exists() and any(root.iterdir()) and not marker.exists():
parser.error('output directory is not an owned Cranpose watchOS build')
root.mkdir(parents=True, exist_ok=True)
marker.touch()
runner = root / 'runner'
source = args.framework_source.resolve() if args.framework_source else None
patches = ''
if source:
workspace = tomllib.loads((source / 'Cargo.toml').read_text())['workspace']
version = workspace['package']['version']
patches = '\n[patch.crates-io]\n'
for name, dependency in workspace['dependencies'].items():
if isinstance(dependency, dict) and 'path' in dependency:
package = dependency.get('package', name)
patches += f'{package} = {{ path = {json.dumps(str(source / dependency["path"]))} }}\n'
else:
version = '=' + (args.cranpose_version or tomllib.loads(
(Path(__file__).resolve().parent.parent / 'Cargo.toml').read_text())['package']['version'])
manifest = f'''[package]
name = "cranpose-watchos-runner"
version = "0.0.0"
edition = "2024"
[workspace]
[lib]
crate-type = ["staticlib", "rlib"]
[dependencies]
application = {{ package = {json.dumps(app['name'])}, path = {json.dumps(str(app_manifest.parent))}, default-features = false, features = {json.dumps([x for x in args.features.split(',') if x])} }}
cranpose = {{ version = {json.dumps(version)}, features = ["watchos"] }}
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
panic = "abort"
''' + patches
write(runner / 'src/lib.rs', rust_bridge(args.entry, args.bundle_id))
app_lock = next((parent / 'Cargo.lock' for parent in app_manifest.parents
if (parent / 'Cargo.lock').is_file()), None)
prepare_manifest(parser, runner, manifest, app_lock)
write(runner / 'build.rs', '''fn main() {
cxx_build::bridge("src/lib.rs").std("c++17").compile("cranpose_watchos_bridge");
println!("cargo:rerun-if-changed=src/lib.rs");
}
''')
templates = Path(__file__).resolve().parent
write(runner / 'src/bin/snapshot.rs', (templates / 'Snapshot.rs').read_text())
for file in ['Host.swift', 'Host.h', 'Host.mm']:
shutil.copyfile(templates / file, root / file)
if args.prepare_only:
return
if sys.platform != 'darwin':
parser.error('Apple compilation requires macOS and the watchOS SDK')
simulator = args.target == 'simulator'
sdk_name = 'watchsimulator' if simulator else 'watchos'
sdk = output(['xcrun', '--sdk', sdk_name, '--show-sdk-path'])
target = 'aarch64-apple-watchos-sim' if simulator else 'aarch64-apple-watchos'
apple_target = f'arm64-apple-watchos{args.deployment_target}' + ('-simulator' if simulator else '')
capabilities = root / 'capabilities'
env = dict(os.environ, SDKROOT=sdk, WATCHOS_DEPLOYMENT_TARGET=args.deployment_target,
CARGO_TARGET_DIR=str(root / 'target'), CRANPOSE_CAPABILITIES_DIR=str(capabilities))
import fcntl
rustup_home = Path(env.get('RUSTUP_HOME', Path.home() / '.rustup'))
rustup_home.mkdir(parents=True, exist_ok=True)
with (rustup_home / 'cranpose-install.lock').open('a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
run(['rustup', 'target', 'add', target], cwd=runner, env=env)
native_libs = root / 'native-static-libs.txt'
locked = [] if source else ['--locked']
run(['cargo', 'rustc', '--lib', '--release', *locked, '--target', target, '--',
f'--print=native-static-libs={native_libs}'], cwd=runner, env=env)
native_object = root / 'Host.o'
run(['xcrun', '--sdk', sdk_name, 'clang++', '-target', apple_target,
'-isysroot', sdk, '-std=c++17', '-fobjc-arc', '-O2', '-c', root / 'Host.mm',
'-I', root / 'target/cxxbridge', '-o', native_object], env=env)
bundle = root / 'CranposeWatch.app'
bundle.mkdir(exist_ok=True)
run(['xcrun', '--sdk', sdk_name, 'swiftc', '-target', apple_target,
'-sdk', sdk, '-O', '-parse-as-library', '-import-objc-header', root / 'Host.h',
root / 'Host.swift', native_object, root / f'target/{target}/release/libcranpose_watchos_runner.a',
'-lc++', *native_libs.read_text().split(),
'-o', bundle / 'CranposeWatch'], env=env)
version = app.get('version')
info = dict(CFBundleIdentifier=args.bundle_id, CFBundleName=args.name,
CFBundleDisplayName=args.name, CFBundleExecutable='CranposeWatch',
CFBundlePackageType='APPL', CFBundleVersion='1',
CFBundleShortVersionString=version if isinstance(version, str) else '0.1',
WKApplication=True,
MinimumOSVersion=args.deployment_target, UIDeviceFamily=[4],
CFBundleSupportedPlatforms=['WatchSimulator' if simulator else 'WatchOS'])
if args.companion:
info['WKCompanionAppBundleIdentifier'] = args.companion
else:
info['WKWatchOnly'] = True
if args.icon:
info.update(compile_icon(args.icon, root, bundle, sdk_name, args.deployment_target))
usage = capabilities / f"{app['name']}-usage.plist"
if usage.is_file():
info.update(plistlib.loads(usage.read_bytes()))
info.update(extra_info)
(bundle / 'Info.plist').write_bytes(plistlib.dumps(info))
if simulator or args.sign:
run(['codesign', '--force', '--sign', args.sign or '-', bundle])
print(f'Built {args.target} app: {bundle}')
if __name__ == '__main__':
main()