From 0ff122f8591797f0ee60c2b6841c8264bdefe80d Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 11:55:32 +0800 Subject: [PATCH 01/16] Build one kernel per hardware platform Hardware now builds one kernel per platform from kernel/series/; devices name their platform and DTB in devices//device.toml. The kernel base is cached per platform (kernel-base-apple) and shared by its devices. The device layer selects the DTB from the base and builds external modules only if a device still has any. The patch marker matches kernel/build. Old per-device caches no longer apply, so the compatibility path for them is gone. m1n1 accepts patches shared by all devices. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/build.py | 48 ++++++++------------- src/orchard_cli/kernel.py | 38 ++++++++++++---- tests/hardware_layout.py | 53 +++++++++++++++++++++++ tests/test_kernel_sdk.py | 12 +++--- tests/test_shared_build_cache.py | 74 +++++++++----------------------- tests/test_sources.py | 14 +++--- 6 files changed, 134 insertions(+), 105 deletions(-) create mode 100644 tests/hardware_layout.py diff --git a/src/orchard_cli/build.py b/src/orchard_cli/build.py index 5aa4d07..cb4cfdf 100644 --- a/src/orchard_cli/build.py +++ b/src/orchard_cli/build.py @@ -24,14 +24,6 @@ from . import sources, kernel as kernels _BASE_PRODUCER = kernels.build_base -# These two old helper modules had the identical base producer below. Their -# whole-file hashes must remain usable, without making future source-management -# fixes invalidate compiled kernels. Never reuse them for a changed producer. -_BASE_V1_PRODUCER = '2404aef309585cb74a49439f7a2df6b1bcb0f24578f765f69ef03f93abab2ec0' -_BASE_V1_HELPERS = ( - '01be4f9189af3896c6acdd71b6309a5244d9585d1ff742daaf57dcfd04bc9fe2', - 'bbe7c3d2431a2069c93cf80d4f942b5f463824adcf2da72516a23721957140f5', -) def configured_device(directory): @@ -91,38 +83,29 @@ def cache_root(): def kernel_base_key(hardware, device, cache): - """Same key before and after source checkout; external modules are separate.""" + """Same key before and after source checkout; external modules are separate. + + The base is per platform, so devices of one platform share it. + """ + platform = kernels.layout(hardware, device)[0] tree = kernels.source_tree(hardware, cache, device) recipes = recipe_key(hardware, [ - 'kernel/upstream.lock', 'kernel/configs/' + device, 'kernel/patches/' + device, + 'kernel/upstream.lock', 'kernel/configs/' + platform, 'kernel/series/' + platform, + 'kernel/series.py', 'kernel/patches', 'kernel/build', 'kernel/Containerfile', 'kernel/install-toolchain', 'kernel/stage-modules.py', 'buildenv.py', ]) # Source preparation is already represented by its resulting tree. Hash # only the binary/SDK producer, not unrelated functions in kernel.py. producer = hashlib.sha256(inspect.getsource(_BASE_PRODUCER).encode()).hexdigest() - helpers = (producer, *_BASE_V1_HELPERS) if producer == _BASE_V1_PRODUCER else (producer,) sdk = digest(Path(__file__).with_name('kernel_sdk.py')) mode = os.environ.get('ORCHARD_BUILD_NATIVE', '0') + ':' + os.environ.get('ORCHARD_BUILD_ENV_ID', '') - keys = [hashlib.sha256((recipes + helper + sdk + tree + mode).encode()).hexdigest() - for helper in helpers] - components = Path(cache) / 'components-v2' - # Prefer a complete matching bundle, then a matching compiled base. Normal - # cached_component validation still verifies every reused file's hash. - for key in keys: - base = components / ('kernel-base-' + device) / key - final = components / ('kernel-' + device) / kernel_key(hardware, device, key) - if (base / 'component.json').is_file() and (final / 'component.json').is_file(): - return key - for key in keys: - if (components / ('kernel-base-' + device) / key / 'component.json').is_file(): - return key - return keys[0] + return hashlib.sha256((recipes + producer + sdk + tree + mode).encode()).hexdigest() def kernel_key(hardware, device, base_key): - """Device layer: external drivers and final DTB, reusing the compiled base.""" - recipes = recipe_key(hardware, ['kernel/modules/' + device, 'kernel/devicetree/' + device, + """Device layer: DTB selection and external drivers, reusing the compiled base.""" + recipes = recipe_key(hardware, ['devices/' + device + '/device.toml', 'kernel/modules/' + device, 'kernel/build-modules', 'kernel/stage-modules.py']) return hashlib.sha256((base_key + recipes).encode()).hexdigest() @@ -190,7 +173,10 @@ def _build_context(hardware, cache, device): def _check_kernel_recipe(hardware, device): - for relative in ('kernel/configs/' + device + '/config', 'kernel/devicetree/' + device + '/build'): + if not (hardware / 'kernel/series.py').is_file(): + raise ValueError(f'Hardware checkout has no kernel series: {hardware}; update hardware') + platform = kernels.layout(hardware, device)[0] + for relative in ('kernel/configs/' + platform + '/config', 'kernel/series/' + platform): if not (hardware / relative).is_file(): raise ValueError(f'Missing hardware recipe input: {hardware / relative}') @@ -198,10 +184,11 @@ def _check_kernel_recipe(hardware, device): def _prepare_kernel(hardware, cache, device): """Resolve one coherent kernel/DTB/module bundle under the component lock.""" components = cache / 'components-v2' + platform = kernels.layout(hardware, device)[0] base_key = lambda: kernel_base_key(hardware, device, cache) def produce_base(stage): kernels.build_base(hardware, cache, device, stage / 'kernel') - base = cached_component(components, 'kernel-base-' + device, base_key, produce_base) / 'kernel' + base = cached_component(components, 'kernel-base-' + platform, base_key, produce_base) / 'kernel' def produce_kernel(stage): kernels.build_device(hardware, cache, device, base, stage / 'kernel') directory = cached_component(components, 'kernel-' + device, @@ -233,7 +220,8 @@ def prepare_components(hardware=None, cache=None, *, device='j71b', kernel=None, components = cache / 'components-v2' if kernel is None: _check_kernel_recipe(hardware, device) - if loader is None and not any((hardware / 'boot/m1n1/patches' / device).glob('*.patch')): + if loader is None and not any([*(hardware / 'boot/m1n1/patches').glob('*.patch'), + *(hardware / 'boot/m1n1/patches' / device).glob('*.patch')]): raise ValueError(f'Missing loader patches for {device}; add a loader recipe or supply --loader FILE') with component_lock(cache): if pongo is None: diff --git a/src/orchard_cli/kernel.py b/src/orchard_cli/kernel.py index 8f20c0c..75a2379 100644 --- a/src/orchard_cli/kernel.py +++ b/src/orchard_cli/kernel.py @@ -2,6 +2,8 @@ Hardware is an unchanged input: use its existing full kernel, module, DTB and staging recipes. Cache policy and module-SDK handling do not live in hardware. +Hardware builds one kernel per platform (kernel/series/); a device +names its platform and DTB in devices//device.toml. """ import json from pathlib import Path @@ -19,16 +21,34 @@ def pin(hardware): if '=' in line) +def _series(hardware, *args): + return subprocess.check_output([sys.executable, str(Path(hardware) / 'kernel/series.py'), *args], + text=True) + + +def layout(hardware, device): + """(platform, dtb) for a device, from hardware's kernel series.""" + descriptor = Path(hardware) / 'devices' / device / 'device.toml' + if not descriptor.is_file(): + raise ValueError(f'Missing hardware recipe input: {descriptor}') + platform, dtb = _series(hardware, '--resolve', device).split() + if dtb == '-': + raise ValueError(f'{device} is a kernel platform, not a device') + return platform, dtb + + def work_directory(hardware, device): # Preserve existing editable source and compiler output; never move/reset it. # These are ignored scratch files, not changes to the hardware repository. - return Path(hardware) / 'kernel/.work' / device + return Path(hardware) / 'kernel/.work' / layout(hardware, device)[0] def patch_identity(hardware, device): - patches = sorted((Path(hardware) / 'kernel/patches' / device).glob('*.patch')) + """Ordered series patches and the marker kernel/build writes for them.""" + kernel = Path(hardware).resolve() / 'kernel' + patches = [Path(line) for line in _series(hardware, device).splitlines() if line] marker = b''.join(subprocess.check_output(['git', 'hash-object', str(p)]).strip() + - b' ' + p.name.encode() + b'\n' for p in patches) + b' ' + str(p.resolve().relative_to(kernel)).encode() + b'\n' for p in patches) return patches, marker @@ -136,6 +156,7 @@ def build_base(hardware, cache, device, output): output.mkdir(parents=True) for name in ('Image.gz', 'kernel.release'): shutil.copy2(work / 'full' / name, output / name) + shutil.copytree(work / 'full/dtbs', output / 'dtbs') shutil.copytree(work / 'in-tree/lib', output / 'lib') export_sdk(compiler_output, output / 'sdk') (output / 'inputs.json').write_text(json.dumps({'source_tree': tree}) + '\n') @@ -144,14 +165,15 @@ def build_base(hardware, cache, device, output): def build_device(hardware, cache, device, base, output): - """Build external modules and final DTB with a PRIVATE cached-SDK copy.""" + """Select the device DTB; build any external modules with a PRIVATE cached-SDK copy.""" from .build import command hardware, base, output = map(Path, (hardware, base, output)) + platform, dtb = layout(hardware, device) source, _ = prepare_source(hardware, cache, device) tree = sources.working_tree(source) if json.loads((base / 'inputs.json').read_text())['source_tree'] != tree: raise ValueError('Cached kernel source identity differs; rebuild the base') - if (base / 'sdk/.config').read_bytes() != (hardware / 'kernel/configs' / device / 'config').read_bytes(): + if (base / 'sdk/.config').read_bytes() != (hardware / 'kernel/configs' / platform / 'config').read_bytes(): raise ValueError('Cached kernel configuration differs; rebuild the base') release = (base / 'kernel.release').read_text().strip() if not release or '/' in release or len(release.split()) != 1: @@ -178,16 +200,14 @@ def build_device(hardware, cache, device, base, output): if (hardware / 'kernel/modules' / device).is_dir(): command(hardware / 'kernel/build-modules', '--source', source, '--build', sdk, '--modules', hardware / 'kernel/modules' / device, - '--localversion', '-orchard-' + device, '--output', work / 'external') + '--localversion', '-orchard-' + platform, '--output', work / 'external') external = ['--external', work / 'external'] command(sys.executable, hardware / 'kernel/stage-modules.py', '--build', sdk, *external, '--output', work / 'staged') - command(hardware / 'kernel/devicetree' / device / 'build', '--kernel-build', sdk, - '--headers', source / 'include', '--output', work / (device + '.dtb')) output.mkdir(parents=True) for name in ('Image.gz', 'kernel.release'): shutil.copy2(base / name, output / name) - shutil.copy2(work / (device + '.dtb'), output / (device + '.dtb')) + shutil.copy2(base / 'dtbs' / (dtb + '.dtb'), output / (device + '.dtb')) shutil.copytree(work / 'staged/lib', output / 'lib') if sources.working_tree(source) != tree: raise ValueError('Kernel source changed during module build; refusing result') diff --git a/tests/hardware_layout.py b/tests/hardware_layout.py new file mode 100644 index 0000000..223ada7 --- /dev/null +++ b/tests/hardware_layout.py @@ -0,0 +1,53 @@ +"""Minimal hardware checkout in the platform-series layout, for kernel cache tests.""" +from pathlib import Path +import shutil + +SERIES = '''#!/usr/bin/env python3 +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parent + + +def resolve(name): + descriptor = ROOT.parent / 'devices' / name / 'device.toml' + if descriptor.is_file(): + values = dict(re.findall(r'^(platform|dtb)\\s*=\\s*"([^"]+)"', descriptor.read_text(), re.M)) + return values['platform'], values['dtb'] + return name, None + + +def patches(name): + result = [] + for line in (ROOT / 'series' / resolve(name)[0]).read_text().splitlines(): + if line.strip(): + result += sorted((ROOT / line.strip()).rglob('*.patch')) + return result + + +if sys.argv[1] == '--resolve': + platform, dtb = resolve(sys.argv[2]) + print(platform, dtb or '-') +else: + for patch in patches(sys.argv[1]): + print(patch) +''' + + +def write(hardware, device='j71b', platform='apple', dtb='t8010-j71b'): + """Series, platform config and device descriptor; returns the patch layer.""" + hardware = Path(hardware) + files = { + 'kernel/series.py': SERIES, + 'kernel/series/' + platform: 'patches/' + platform + '\n', + 'kernel/configs/' + platform + '/config': 'config', + 'devices/' + device + '/device.toml': f'[kernel]\nplatform = "{platform}"\ndtb = "{dtb}"\n', + } + for name, text in files.items(): + path = hardware / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) + layer = hardware / 'kernel/patches' / platform + layer.mkdir(parents=True, exist_ok=True) + return layer diff --git a/tests/test_kernel_sdk.py b/tests/test_kernel_sdk.py index 56fd260..c8d0d5d 100644 --- a/tests/test_kernel_sdk.py +++ b/tests/test_kernel_sdk.py @@ -11,6 +11,8 @@ import orchard_hardware from orchard_cli import build, kernel, kernel_sdk +import hardware_layout + class KernelSDKTests(unittest.TestCase): def setUp(self): @@ -18,9 +20,8 @@ def setUp(self): self.addCleanup(temporary.cleanup) self.root = Path(temporary.name).resolve() self.hardware = self.root / 'hardware' - config = self.hardware / 'kernel/configs/j71b/config' - config.parent.mkdir(parents=True) - config.write_text('config\n') + hardware_layout.write(self.hardware) + (self.hardware / 'kernel/configs/apple/config').write_text('config\n') self.original_hardware = Path(orchard_hardware.__file__).resolve().parent def compiler_output(self): @@ -56,6 +57,8 @@ def test_module_only_build_uses_original_hardware_api_and_private_sdk(self): (base / 'kernel.release').write_text('test-release\n') (base / 'Image.gz').write_text('cached kernel') (base / 'inputs.json').write_text(json.dumps({'source_tree': 'tree'})) + (base / 'dtbs').mkdir() + (base / 'dtbs/t8010-j71b.dtb').write_text('device dtb') modules = base / 'lib/modules/test-release/kernel/drivers' modules.mkdir(parents=True) (modules / 'keep.ko').write_text('cached upstream module') @@ -88,8 +91,6 @@ def command(*argv, **kwargs): patch.object(stage.buildenv, 'native', return_value=True), \ patch.object(stage.subprocess, 'run'): stage.main() - elif Path(argv[0]) == self.hardware / 'kernel/devicetree/j71b/build': - Path(argv[argv.index('--output') + 1]).write_text('device dtb') output = self.root / 'result' with patch.object(kernel, 'prepare_source', return_value=(source, compiler)), \ patch.object(kernel.sources, 'working_tree', return_value='tree'), \ @@ -100,6 +101,7 @@ def command(*argv, **kwargs): self.assertEqual((result / 'extra/replace.ko').read_text(), 'new external module') self.assertFalse((result / 'kernel/drivers/replace.ko').exists()) self.assertEqual((output / 'Image.gz').read_text(), 'cached kernel') + self.assertEqual((output / 'j71b.dtb').read_text(), 'device dtb') self.assertEqual(snapshot, {str(p.relative_to(base)): build.digest(p) for p in base.rglob('*') if p.is_file()}) self.assertFalse(any(Path(args[0]) == self.hardware / 'kernel/build' for args in calls)) diff --git a/tests/test_shared_build_cache.py b/tests/test_shared_build_cache.py index 6625fb2..07383cf 100644 --- a/tests/test_shared_build_cache.py +++ b/tests/test_shared_build_cache.py @@ -11,6 +11,8 @@ from orchard_cli import build, sources, kernel +import hardware_layout + class SharedBuildCacheTests(unittest.TestCase): def setUp(self): @@ -32,15 +34,13 @@ def setUp(self): for name, text in { 'kernel/build': 'producer', 'kernel/build-modules': 'external producer', 'kernel/stage-modules.py': 'stage', - 'kernel/configs/j71b/config': 'config', - 'kernel/devicetree/j71b/overlay': 'dtb', - 'kernel/devicetree/j71b/build': 'dtb producer', 'kernel/modules/j71b/power/driver.c': 'external driver', 'kernel/upstream.lock': f'url={self.upstream.as_uri()}\ncommit={self.commit}\n', }.items(): path = hardware / name path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text) + hardware_layout.write(hardware) self.overrides = {} for name in ('busybox', 'pongo', 'loader'): path = self.root / name @@ -51,7 +51,7 @@ def setUp(self): def produce(self, hardware, cache, device, output, base=None): self.assertEqual(cache, self.cache) self.assertEqual(device, 'j71b') - source = hardware / 'kernel/.work/j71b/source' + source = hardware / 'kernel/.work/apple/source' if not source.exists(): sources.checkout(self.upstream.as_uri(), self.commit, source, cache=self.cache) output.mkdir(parents=True) @@ -65,6 +65,9 @@ def produce(self, hardware, cache, device, output, base=None): (module_tree / 'upstream.ko').write_text('upstream module') (output / 'sdk').mkdir() (output / 'sdk/Module.symvers').write_text('matching symbols') + (output / 'dtbs').mkdir() + for dtb in ('t8010-j71b', 't8010-j72b'): + (output / 'dtbs' / (dtb + '.dtb')).write_text(dtb) else: self.calls.append('external-modules-and-dtb') self.assertTrue((base / 'sdk/Module.symvers').is_file()) @@ -73,7 +76,8 @@ def produce(self, hardware, cache, device, output, base=None): shutil.copytree(base / 'lib', output / 'lib') (output / 'lib/modules/test-release/external.ko').write_text( (hardware / 'kernel/modules/j71b/power/driver.c').read_text()) - (output / 'j71b.dtb').write_text((hardware / 'kernel/devicetree/j71b/overlay').read_text()) + dtb = kernel.layout(hardware, device)[1] + shutil.copyfile(base / 'dtbs' / (dtb + '.dtb'), output / 'j71b.dtb') def prepare(self, hardware): with patch.object(kernel, 'build_base', side_effect=self.produce), \ @@ -126,58 +130,21 @@ def test_kernel_defaults_to_active_hardware_and_shared_cache_environment(self): def test_cache_misses_and_hits_are_visible_without_changing_identity(self): with redirect_stdout(io.StringIO()) as first_log: first = self.prepare(self.one) - self.assertIn('Building kernel-base-j71b (cache miss):', first_log.getvalue()) + self.assertIn('Building kernel-base-apple (cache miss):', first_log.getvalue()) self.assertIn('Building kernel-j71b (cache miss):', first_log.getvalue()) with redirect_stdout(io.StringIO()) as second_log: second = build.prepare_kernel(self.two, self.cache) self.assertEqual(first['kernel'], second) - self.assertIn('Reusing kernel-base-j71b:', second_log.getvalue()) + self.assertIn('Reusing kernel-base-apple:', second_log.getvalue()) self.assertIn('Reusing kernel-j71b:', second_log.getvalue()) self.assertNotIn('Building ', second_log.getvalue()) self.assertEqual(self.calls, ['kernel-base', 'external-modules-and-dtb']) - def legacy_key(self, helper): - # Historical formula, kept independent of the new producer fingerprint. - recipes = build.recipe_key(self.one, [ - 'kernel/upstream.lock', 'kernel/configs/j71b', 'kernel/patches/j71b', - 'kernel/build', 'kernel/Containerfile', 'kernel/install-toolchain', - 'kernel/stage-modules.py', 'buildenv.py', - ]) - sdk = build.digest(Path(build.__file__).with_name('kernel_sdk.py')) - tree = kernel.source_tree(self.one, self.cache, 'j71b') - mode = os.environ.get('ORCHARD_BUILD_NATIVE', '0') + ':' + os.environ.get('ORCHARD_BUILD_ENV_ID', '') - return hashlib.sha256((recipes + helper + sdk + tree + mode).encode()).hexdigest() - - def seed_legacy_bundle(self, helper): - key = self.legacy_key(helper) - components = self.cache / 'components-v2' - base = build.cached_component(components, 'kernel-base-j71b', lambda: key, - lambda stage: self.produce(self.one, self.cache, 'j71b', stage / 'kernel')) / 'kernel' - final_key = build.kernel_key(self.one, 'j71b', key) - final = build.cached_component(components, 'kernel-j71b', lambda: final_key, - lambda stage: self.produce(self.one, self.cache, 'j71b', stage / 'kernel', base)) / 'kernel' - return key, base, final - - def test_old_canonical_and_worker_cache_bundles_survive_source_helper_changes(self): - for helper in build._BASE_V1_HELPERS: - with self.subTest(helper=helper): - key, base, final = self.seed_legacy_bundle(helper) - with patch.object(kernel, 'build_base', side_effect=AssertionError('Linux rebuilt')), \ - patch.object(kernel, 'build_device', side_effect=AssertionError('modules rebuilt')), \ - patch.object(sources, 'checkout', side_effect=AssertionError('source checked out')): - self.assertEqual(build.kernel_base_key(self.two, 'j71b', self.cache), key) - self.assertEqual(build.prepare_kernel(self.two, self.cache), final) - self.assertFalse((self.two / 'kernel/.work').exists()) - shutil.rmtree(self.cache / 'components-v2') - - def test_legacy_cache_is_not_reused_when_base_producer_changes(self): - key, _, _ = self.seed_legacy_bundle(build._BASE_V1_HELPERS[0]) - with patch.object(build.inspect, 'getsource', return_value='changed base producer\n'): - self.assertNotEqual(build.kernel_base_key(self.two, 'j71b', self.cache), key) - - def test_reused_legacy_cache_is_still_hash_verified(self): - _, base, _ = self.seed_legacy_bundle(build._BASE_V1_HELPERS[0]) + def test_reused_cache_is_still_hash_verified(self): + first = self.prepare(self.one) + base = next((self.cache / 'components-v2/kernel-base-apple').iterdir()) / 'kernel' (base / 'Image.gz').write_bytes(b'corrupted') + shutil.rmtree(first['kernel'].parent) with patch.object(kernel, 'build_base', side_effect=AssertionError('silently rebuilt')), \ self.assertRaisesRegex(ValueError, 'Cached component changed'): build.prepare_kernel(self.two, self.cache) @@ -212,10 +179,10 @@ def test_readme_other_device_and_firmware_changes_do_not_rebuild_kernel(self): def test_config_and_local_in_tree_edits_rebuild_the_base_without_reset(self): self.prepare(self.one) - (self.two / 'kernel/configs/j71b/config').write_text('changed config') + (self.two / 'kernel/configs/apple/config').write_text('changed config') self.prepare(self.two) self.assertEqual(self.calls.count('kernel-base'), 2) - source = self.one / 'kernel/.work/j71b/source' + source = self.one / 'kernel/.work/apple/source' (source / 'driver.c').write_text('local experimental kernel driver\n') self.prepare(self.one) self.assertEqual(self.calls.count('kernel-base'), 3) @@ -231,10 +198,11 @@ def produce(stage): self.assertFalse((self.cache / 'race/before').exists()) self.assertFalse((self.cache / 'race/after').exists()) - def test_dtb_edit_only_rebuilds_device_layer(self): + def test_dtb_selection_only_rebuilds_device_layer(self): self.prepare(self.one) - (self.two / 'kernel/devicetree/j71b/overlay').write_text('changed overlay') - self.prepare(self.two) + hardware_layout.write(self.two, dtb='t8010-j72b') + second = self.prepare(self.two) + self.assertEqual((second['kernel'] / 'j71b.dtb').read_text(), 't8010-j72b') self.assertEqual(self.calls.count('kernel-base'), 1) self.assertEqual(self.calls.count('external-modules-and-dtb'), 2) diff --git a/tests/test_sources.py b/tests/test_sources.py index d603223..1d5f644 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -9,6 +9,8 @@ from orchard_cli import sources +import hardware_layout + class SourceCacheTests(unittest.TestCase): def setUp(self): @@ -69,8 +71,7 @@ def test_local_checkout_can_seed_cache_without_remote_access(self): def test_cli_prepares_source_for_unmodified_hardware_builder_without_resetting_edits(self): from orchard_cli import kernel hardware = self.root / 'hardware' - patches = hardware / 'kernel/patches/j71b' - patches.mkdir(parents=True) + patches = hardware_layout.write(hardware) (hardware / 'kernel/upstream.lock').write_text(f'url={self.url}\ncommit={self.commit}\n') (self.upstream / 'driver.c').write_text('patched\n') patchfile = patches / '0001-driver.patch' @@ -91,8 +92,7 @@ def test_cli_prepares_source_for_unmodified_hardware_builder_without_resetting_e def test_patch_transition_uses_central_cache_then_retains_incremental_output(self): from orchard_cli import build, kernel hardware = self.root / 'hardware' - patches = hardware / 'kernel/patches/j71b' - patches.mkdir(parents=True) + patches = hardware_layout.write(hardware) (hardware / 'kernel/upstream.lock').write_text(f'url={self.url}\ncommit={self.commit}\n') patchfile = patches / '0001-driver.patch' def change(text): @@ -145,8 +145,7 @@ def test_patch_transitions_preserve_blank_context_and_binary_patch_bytes(self): for number, (first, second) in enumerate(cases): with self.subTest(number=number): hardware = self.root / ('hardware-' + str(number)) - patches = hardware / 'kernel/patches/j71b' - patches.mkdir(parents=True) + patches = hardware_layout.write(hardware) (hardware / 'kernel/upstream.lock').write_text(f'url={self.url}\ncommit={self.commit}\n') def change(content): (self.upstream / 'driver.c').write_bytes(content) @@ -164,8 +163,7 @@ def change(content): def test_changed_patches_with_user_edits_cannot_hit_unrelated_cache(self): from orchard_cli import build, kernel hardware = self.root / 'hardware' - patches = hardware / 'kernel/patches/j71b' - patches.mkdir(parents=True) + patches = hardware_layout.write(hardware) (hardware / 'kernel/upstream.lock').write_text(f'url={self.url}\ncommit={self.commit}\n') source, output = kernel.prepare_source(hardware, self.cache, 'j71b') (source / 'driver.c').write_text('user edit\n') From 34279ee4ed9bba729a3af1fe90b2a1e1b2033c6f Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 13:58:38 +0800 Subject: [PATCH 02/16] Decode installed kernelcaches for on-device firmware preparation apple/kernelcache decodes the installed IMG4/LZFSE kernelcache through Apple's system library, so the in-app installer can extract SmartIO firmware on iPadOS. ipsw imports the release downloader lazily, so the pure firmware selector works on-device without download tooling. Brought over unchanged from the uncommitted work in worktrees/cli-local-firmware. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/apple/ipsw.py | 3 +- src/orchard_cli/apple/kernelcache.py | 83 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 src/orchard_cli/apple/kernelcache.py diff --git a/src/orchard_cli/apple/ipsw.py b/src/orchard_cli/apple/ipsw.py index 51c3640..ccbe4a8 100644 --- a/src/orchard_cli/apple/ipsw.py +++ b/src/orchard_cli/apple/ipsw.py @@ -11,7 +11,6 @@ from orchard_cli.cache import cache_root from orchard_cli.integrity import digest -from orchard_cli.releases import materialize TOOL = {'url': 'https://github.com/blacktop/ipsw/releases/download/v3.1.739/ipsw_3.1.739_macOS_arm64.tar.gz', 'sha256': 'c09e8eba02c332f5b50d15f94383e59dd35646d70f6ea9e78d185e9afda6abc2', @@ -46,6 +45,8 @@ def hcd_from_binary(data, expected): def tool(clock, progress): + # Keep the pure firmware selector usable on-device without download tooling. + from orchard_cli.releases import materialize supplied = os.environ.get('ORCHARD_IPSW_TOOL') or shutil.which('ipsw') if supplied: return supplied diff --git a/src/orchard_cli/apple/kernelcache.py b/src/orchard_cli/apple/kernelcache.py new file mode 100644 index 0000000..831cd0f --- /dev/null +++ b/src/orchard_cli/apple/kernelcache.py @@ -0,0 +1,83 @@ +"""Bounded installed IMG4/LZFSE kernel decoding using Apple's system library.""" +import ctypes +import struct + +MAX_INPUT = 64 * 1024 * 1024 +MAX_OUTPUT = 128 * 1024 * 1024 + + +def der_items(blob): + pos = 0 + while pos < len(blob): + if pos + 2 > len(blob): + raise ValueError('Truncated DER header') + tag, length = blob[pos:pos+2] + pos += 2 + if length & 128: + count = length & 127 + if not 1 <= count <= 4 or pos+count > len(blob) or blob[pos] == 0: + raise ValueError('Invalid DER length') + length = int.from_bytes(blob[pos:pos+count], 'big') + pos += count + if length < 128: + raise ValueError('Noncanonical DER length') + if pos+length > len(blob): + raise ValueError('Truncated DER value') + yield tag, blob[pos:pos+length] + pos += length + + +def kernel_payload(blob): + if not 1 <= len(blob) <= MAX_INPUT: + raise ValueError('Kernel input exceeds bounds') + outer = list(der_items(blob)) + if len(outer) != 1 or outer[0][0] != 0x30: + raise ValueError('Expected one IMG4/IM4P sequence') + nodes = list(der_items(outer[0][1])) + if nodes and nodes[0] == (0x16, b'IMG4'): + if len(nodes) < 2 or nodes[1][0] != 0x30: + raise ValueError('Missing IM4P') + nodes = list(der_items(nodes[1][1])) + if (len(nodes) < 4 or nodes[0] != (0x16, b'IM4P') or + nodes[1] != (0x16, b'krnl') or nodes[2][0] != 0x16 or nodes[3][0] != 4): + raise ValueError('Not a kernel IM4P payload') + return nodes[3][1] + + +def arm64_slice(blob): + if blob[:4] == b'\xca\xfe\xba\xbe': + if len(blob) < 8: + raise ValueError('Truncated fat kernel') + count = struct.unpack_from('>I', blob, 4)[0] + if not 1 <= count <= 16 or 8+20*count > len(blob): + raise ValueError('Invalid fat architecture table') + selected = [] + for i in range(count): + cpu, subtype, offset, size, alignment = struct.unpack_from('>5I', blob, 8+20*i) + if offset < 8+20*count or offset+size > len(blob) or alignment > 31 or offset % (1 << alignment): + raise ValueError('Invalid fat slice bounds/alignment') + if cpu == 0x100000c: + selected.append(blob[offset:offset+size]) + if len(selected) != 1: + raise ValueError('No unique ARM64 kernel slice') + blob = selected[0] + if len(blob) < 32 or struct.unpack_from(' Date: Wed, 28 Oct 2026 14:02:18 +0800 Subject: [PATCH 03/16] Prepare J71b firmware without the device ADT The touch converter writes calibration records instead of baking the unit's calibration into the firmware. apple_z2 fills them from the DT properties m1n1 copies from the ADT at boot. The firmware file is now the same for every J71b (sha256 ec4349481fbdc1e6, validated on device). prepare/mixin, install and launch no longer capture the ADT. The capture-adt command stays as a bring-up tool. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/command.py | 4 +- src/orchard_cli/contracts.py | 3 +- src/orchard_cli/j71b/mixin.py | 11 +++--- src/orchard_cli/j71b/prepare.py | 15 +++----- src/orchard_cli/j71b/touch.py | 65 ++++++++++----------------------- src/orchard_cli/launch.py | 5 +-- src/orchard_cli/service.py | 5 +-- tests/test_binary_codecs.py | 18 +++++---- tests/test_launch.py | 4 +- 9 files changed, 48 insertions(+), 82 deletions(-) diff --git a/src/orchard_cli/command.py b/src/orchard_cli/command.py index eb83fbe..cf59957 100644 --- a/src/orchard_cli/command.py +++ b/src/orchard_cli/command.py @@ -62,7 +62,7 @@ def main(): adt = commands.add_parser('capture-adt', help='save a fresh recovery ADT privately') adt.add_argument('--output', type=Path, required=True) mixin = commands.add_parser('mixin', help='prepare a data-only private mixin') - for name in ('adt', 'firmware-dir', 'output'): + for name in ('firmware-dir', 'output'): mixin.add_argument('--' + name, type=Path, required=True) for action in ('status', 'diagnostics', 'targets', 'destination', 'prepare', 'install-root', 'handoff', 'abort'): command = commands.add_parser(action, help='invoke the device-owned ' + action + ' operation') @@ -148,7 +148,7 @@ def main(): result = {'saved': str(args.output), 'bytes': len(data)} elif args.action == 'mixin': adapter = load() - archive, result = adapter.prepare_mixin(args.adt, args.firmware_dir, args.output) + archive, result = adapter.prepare_mixin(args.firmware_dir, args.output) result = {**result, 'path': str(archive)} else: adapter, device = choose() diff --git a/src/orchard_cli/contracts.py b/src/orchard_cli/contracts.py index 0a32e53..767cc90 100644 --- a/src/orchard_cli/contracts.py +++ b/src/orchard_cli/contracts.py @@ -97,5 +97,4 @@ def capture_adt(self, device: Optional[SelectedDevice] = None) -> bytes: ... def firmware_inputs(self, clock: Clock, progress: Progress, *, allow_download: bool = True) -> Path: ... - def prepare_mixin(self, adt: Path, firmware_dir: Path, - outdir: Path) -> Tuple[Path, MixinReceipt]: ... + def prepare_mixin(self, firmware_dir: Path, outdir: Path) -> Tuple[Path, MixinReceipt]: ... diff --git a/src/orchard_cli/j71b/mixin.py b/src/orchard_cli/j71b/mixin.py index f4869bd..9f1ddd5 100644 --- a/src/orchard_cli/j71b/mixin.py +++ b/src/orchard_cli/j71b/mixin.py @@ -1,4 +1,4 @@ -"""Prepare J71b's private inputs, then use the shared data-only mixin packer.""" +"""Prepare J71b's firmware, then use the shared data-only mixin packer.""" from pathlib import Path from typing import Tuple @@ -8,12 +8,11 @@ from .config import MANIFEST, PROFILE -def build(adt: Path, firmware_dir: Path, outdir: Path) -> Tuple[Path, MixinReceipt]: +def build(firmware_dir: Path, outdir: Path) -> Tuple[Path, MixinReceipt]: if outdir.exists(): raise ValueError('Choose a new private output directory') outdir.mkdir(parents=True, mode=0o700) - prepared = prepare(adt, firmware_dir, outdir / 'prepared') - receipt = verify(prepared, MANIFEST, adt) - metadata = {'schema': 1, 'profile': PROFILE, 'adt_sha256': receipt['adt_sha256'], - 'files': receipt['files']} + prepared = prepare(firmware_dir, outdir / 'prepared') + receipt = verify(prepared, MANIFEST) + metadata = {'schema': 1, 'profile': PROFILE, 'files': receipt['files']} return pack_mixin(prepared, metadata, outdir) diff --git a/src/orchard_cli/j71b/prepare.py b/src/orchard_cli/j71b/prepare.py index e123c4a..f0cdca2 100644 --- a/src/orchard_cli/j71b/prepare.py +++ b/src/orchard_cli/j71b/prepare.py @@ -7,18 +7,15 @@ import tempfile from orchard_cli.staging import stage as stage_firmware -from orchard_hardware.apple.adt import validate as validate_adt from orchard_cli.integrity import digest from .touch import prepare as prepare_touch from .config import MANIFEST, APPLE_PRODUCT -def verify(directory: Path, manifest: Path, adt: Path) -> dict: +def verify(directory: Path, manifest: Path) -> dict: record = json.loads((directory / 'firmware.json').read_text()) - device_id = f'{validate_adt(adt.read_bytes()):016x}' if (record.get('schema') != 1 or record.get('profile') != 'j71b' or - record.get('device_id') != device_id or record.get('adt_sha256') != digest(adt) or record.get('manifest_sha256') != digest(manifest)): raise ValueError('Prepared firmware identity differs') files = record['files'] @@ -47,7 +44,7 @@ def check_outdir(outdir: Path): raise ValueError('Outdir must be absent or an empty directory') -def prepare(adt: Path, firmware_dir: Path, outdir: Path) -> Path: +def prepare(firmware_dir: Path, outdir: Path) -> Path: manifest = MANIFEST spec = json.loads(manifest.read_text()) if spec.get('device') != APPLE_PRODUCT: @@ -59,7 +56,6 @@ def prepare(adt: Path, firmware_dir: Path, outdir: Path) -> Path: outdir = outdir.resolve() if outdir == firmware_dir or firmware_dir in outdir.parents: raise ValueError('Output must not be inside the firmware inputs') - device_id = f'{validate_adt(adt.read_bytes()):016x}' source = next(item for item in spec['extracted_inputs'] if item['name'] == 'J71b.mtprops') matches = [path for path in firmware_dir.rglob(source['name']) if path.is_file() and not path.is_symlink() and digest(path) == source['sha256']] @@ -70,7 +66,7 @@ def prepare(adt: Path, firmware_dir: Path, outdir: Path) -> Path: work = Path(temporary) touch_dir = work / 'touch' touch_dir.mkdir() - prepare_touch(matches[0], adt, touch_dir / 'j71b-fwstart.z2fw', touch_dir / 'touch.json') + prepare_touch(matches[0], touch_dir / 'j71b-fwstart.z2fw', touch_dir / 'touch.json') count = stage_firmware(manifest, [firmware_dir, touch_dir], [touch_dir / 'touch.json'], work / 'firmware') expected_count = len(spec['files']) + len(spec['generated']) @@ -81,10 +77,9 @@ def prepare(adt: Path, firmware_dir: Path, outdir: Path) -> Path: if path.is_file(): records[path.relative_to(work / 'firmware').as_posix()] = { 'bytes': path.stat().st_size, 'sha256': digest(path)} - receipt = {'schema': 1, 'profile': 'j71b', 'device_id': device_id, - 'manifest_sha256': digest(manifest), 'adt_sha256': digest(adt), 'files': records} + receipt = {'schema': 1, 'profile': 'j71b', 'manifest_sha256': digest(manifest), 'files': records} (work / 'firmware.json').write_text(json.dumps(receipt, indent=2) + '\n') - verify(work, manifest, adt) + verify(work, manifest) check_outdir(outdir) os.replace(work, outdir) return outdir diff --git a/src/orchard_cli/j71b/touch.py b/src/orchard_cli/j71b/touch.py index 5305984..0bb3c12 100644 --- a/src/orchard_cli/j71b/touch.py +++ b/src/orchard_cli/j71b/touch.py @@ -1,6 +1,9 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-or-later -"""Convert local J71b touch firmware and ADT calibration to Z2FW. +"""Convert J71b touch firmware to Z2FW with calibration records. + +The file is the same for every J71b: apple_z2 fills each calibration record +from the DT properties m1n1 copies from the ADT at boot. Packet layouts/Z2FW framing informed by Corellium Project Sandcastle and Pauli1Go's GPL makez2fw (944541e67b4c603df328c77d30093551de802c17). @@ -13,13 +16,13 @@ import plistlib import struct -from orchard_cli.apple.adt_tree import parse, u32s from orchard_cli.apple.osserialize import apple_xml MAGIC = 0x5746325a -INIT, SEND, WAIT, CONFIG = 0, 1, 3, 4 -PROVIDERS = {'multi-touch-calibration', 'multitouch-to-display-offset', - 'firefly-calibration', 'pressure-effect-correction'} +INIT, SEND, WAIT, CONFIG, CALIBRATION = 0, 1, 3, 4, 6 +# apple_z2 calibration provider IDs (drivers/input/touchscreen/apple_z2.c). +PROVIDERS = {'multi-touch-calibration': 0, 'multitouch-to-display-offset': 4, + 'firefly-calibration': 5, 'pressure-effect-correction': 6} def uint(value, name, maximum=0xffffffff): @@ -69,20 +72,6 @@ def decode_blob(packet): return address, payload -def calibration_packet(address, data): - address = uint(address, 'calibration address') - if not data or len(data) > 0x40000 or address + ((len(data)+3)&~3) > 0x100000000: - raise ValueError('Calibration size/address out of bounds') - padded = data + b'\0' * (-len(data) % 4) - fields = struct.pack('>H', len(padded)//4-1) + xe32(address) - packet = (b'\x18\xe1\x30\x01' + fields + struct.pack('>H', sum(fields)) - + swapped(padded) + xe32(sum(data) & 0xffffffff)) - parsed_address, parsed_data = decode_blob(packet) - if parsed_address != address or parsed_data != padded: - raise ValueError('Calibration encode/decode roundtrip mismatch') - return packet - - def register_packet(address, mask, value): fields = xe32(uint(address, 'register address')) + xe32(uint(mask, 'register mask')) fields += xe32(uint(value, 'register value')) @@ -104,7 +93,7 @@ def decode_container(blob): code, size = struct.unpack_from(' len(blob): + if code not in (INIT, SEND, WAIT, CONFIG, CALIBRATION) or next_pos > len(blob): raise ValueError('Unknown or truncated Z2FW command') if any(blob[pos+size:next_pos]): raise ValueError('Nonzero Z2FW padding') @@ -113,13 +102,9 @@ def decode_container(blob): return records -def convert(profile, config, properties): +def convert(profile, config): if profile.get('PreconstructedBootloadPacketType') != 'K1HBPP14' or config.get('Protocol') != 'Z2': raise ValueError('Unexpected packet type or protocol') - if properties.get('hid-merge-personality', b'').rstrip(b'\0') != b'K1F19,4': - raise ValueError('Live touch profile is not K1F19,4') - if u32s(properties.get('hbpp-version', b'')) != [0x104]: - raise ValueError('Live HBPP version is not 0x104') payload = profile.get('Constructed Firmware') if not isinstance(payload, bytes): raise ValueError('Expected data-form constructed firmware') @@ -130,7 +115,7 @@ def convert(profile, config, properties): if not isinstance(calibration, list) or len(calibration) != 4: raise ValueError('Expected exactly four calibration entries') names = [item.get('Provider') for item in calibration] - if set(names) != PROVIDERS or len(set(names)) != len(names): + if set(names) != set(PROVIDERS) or len(set(names)) != len(names): raise ValueError('Missing, duplicate, or unsupported calibration provider') if not isinstance(config.get('Power Off On Reset'), bool): raise ValueError('Power Off On Reset must be a boolean') @@ -154,18 +139,16 @@ def convert(profile, config, properties): addresses = [] for item in calibration: name = item['Provider'] - data = properties.get(name) maximum = uint(item['Max Size'], 'calibration maximum', 0x40000) - if not isinstance(data, bytes) or not data or len(data) > maximum or data.startswith(b'syscfg/'): - raise ValueError('Missing, oversized, or unresolved calibration: ' + name) + if not maximum: + raise ValueError('Empty calibration range: ' + name) address = uint(item['Address'], 'calibration address') - end = address + ((len(data)+3)&~3) + end = address + ((maximum+3)&~3) if end > 0x100000000 or any(address < b and a < end for a, b in addresses): raise ValueError('Overlapping or overflowing calibration ranges') addresses.append((address, end)) - records.append((SEND, calibration_packet(address, data))) - providers.append({'provider': name, 'bytes': len(data), 'maximum': maximum, - 'address': address}) + records.append((CALIBRATION, struct.pack(' Date: Wed, 28 Oct 2026 14:30:20 +0800 Subject: [PATCH 04/16] Version 0.1.11; require the torvalds-based hardware Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 87f13ae..7286419 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "orchard-cli" -version = "0.1.10" +version = "0.1.11" description = "Apple device Linux boot and installation tooling" readme = "README.md" license = "GPL-2.0-or-later AND MIT" license-files = ["LICENSE", "NOTICE", "LICENSES/*"] requires-python = ">=3.9" -dependencies = ["orchard-hardware>=0.1.10,<0.2", "jsonschema>=4.25,<5", "tomli>=2; python_version < '3.11'"] +dependencies = ["orchard-hardware>=0.1.11,<0.2", "jsonschema>=4.25,<5", "tomli>=2; python_version < '3.11'"] [project.urls] Repository = "https://github.com/OrchardLinux/cli" From d0e18291f315a0f7c1412067cf31cdb1d0ad6f0f Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 14:54:29 +0800 Subject: [PATCH 05/16] Run firmware recipes as declarative steps recipe.py implements the step vocabulary (copy, bluetool-hcd, smartio-from-kernelcache, im4p-payload, z2fw-from-mtprops). It checks a recipe against the hardware schema and refuses unknown steps with a request to update Orchard, before any work. firmware.py extracts only the IPSW inputs that missing outputs need, runs the steps, and caches outputs by their pinned hash; the mixin stages them under their destinations. The J71b-specific acquisition, preparation and staging code is gone; touch conversion (apple/z2fw.py) and SmartIO extraction (apple/smartio.py) are generic, with device facts coming from the recipe. Verified on J71b's pinned IPSW: all 10 outputs match the recipe, SmartIO and Bluetooth byte-identical to the previous cache. Co-Authored-By: Claude Opus 5.5 --- .../extract_smartio.py => apple/smartio.py} | 0 .../{j71b/touch.py => apple/z2fw.py} | 79 ++-------- src/orchard_cli/firmware.py | 87 +++++++++++ src/orchard_cli/j71b/adapter.py | 7 +- src/orchard_cli/j71b/inputs.py | 53 ------- src/orchard_cli/j71b/mixin.py | 18 --- src/orchard_cli/j71b/prepare.py | 85 ----------- src/orchard_cli/manifests.py | 5 + src/orchard_cli/recipe.py | 140 ++++++++++++++++++ src/orchard_cli/staging.py | 83 ----------- tests/test_binary_codecs.py | 2 +- tests/test_recipe.py | 69 +++++++++ tests/test_staging.py | 42 ------ 13 files changed, 322 insertions(+), 348 deletions(-) rename src/orchard_cli/{j71b/extract_smartio.py => apple/smartio.py} (100%) rename src/orchard_cli/{j71b/touch.py => apple/z2fw.py} (68%) delete mode 100644 src/orchard_cli/j71b/inputs.py delete mode 100644 src/orchard_cli/j71b/mixin.py delete mode 100644 src/orchard_cli/j71b/prepare.py create mode 100644 src/orchard_cli/recipe.py delete mode 100644 src/orchard_cli/staging.py create mode 100644 tests/test_recipe.py delete mode 100644 tests/test_staging.py diff --git a/src/orchard_cli/j71b/extract_smartio.py b/src/orchard_cli/apple/smartio.py similarity index 100% rename from src/orchard_cli/j71b/extract_smartio.py rename to src/orchard_cli/apple/smartio.py diff --git a/src/orchard_cli/j71b/touch.py b/src/orchard_cli/apple/z2fw.py similarity index 68% rename from src/orchard_cli/j71b/touch.py rename to src/orchard_cli/apple/z2fw.py index 0bb3c12..d510309 100644 --- a/src/orchard_cli/j71b/touch.py +++ b/src/orchard_cli/apple/z2fw.py @@ -1,22 +1,15 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-or-later -"""Convert J71b touch firmware to Z2FW with calibration records. +"""Convert an Apple multitouch profile (mtprops) to Z2FW with calibration records. -The file is the same for every J71b: apple_z2 fills each calibration record -from the DT properties m1n1 copies from the ADT at boot. +The file is the same for every unit of a model: apple_z2 fills each +calibration record from the DT properties m1n1 copies from the ADT at boot. Packet layouts/Z2FW framing informed by Corellium Project Sandcastle and Pauli1Go's GPL makez2fw (944541e67b4c603df328c77d30093551de802c17). """ -import argparse -import hashlib -import json -import os -from pathlib import Path -import plistlib import struct -from orchard_cli.apple.osserialize import apple_xml MAGIC = 0x5746325a INIT, SEND, WAIT, CONFIG, CALIBRATION = 0, 1, 3, 4, 6 @@ -102,21 +95,21 @@ def decode_container(blob): return records -def convert(profile, config): - if profile.get('PreconstructedBootloadPacketType') != 'K1HBPP14' or config.get('Protocol') != 'Z2': +def convert(profile, config, *, packet, load_address, hbpp_version): + if profile.get('PreconstructedBootloadPacketType') != packet or config.get('Protocol') != 'Z2': raise ValueError('Unexpected packet type or protocol') payload = profile.get('Constructed Firmware') if not isinstance(payload, bytes): raise ValueError('Expected data-form constructed firmware') firmware_address, firmware_data = decode_blob(payload) - if firmware_address != 0x100000: - raise ValueError('Unexpected J71b firmware load address') - calibration = config.get('Calibration Sequence') - if not isinstance(calibration, list) or len(calibration) != 4: - raise ValueError('Expected exactly four calibration entries') + if firmware_address != load_address: + raise ValueError('Unexpected firmware load address') + calibration = config.get('Calibration Sequence', []) + if not isinstance(calibration, list): + raise ValueError('Unexpected calibration sequence shape') names = [item.get('Provider') for item in calibration] - if set(names) != set(PROVIDERS) or len(set(names)) != len(names): - raise ValueError('Missing, duplicate, or unsupported calibration provider') + if not set(names) <= set(PROVIDERS) or len(set(names)) != len(names): + raise ValueError('Duplicate or unsupported calibration provider') if not isinstance(config.get('Power Off On Reset'), bool): raise ValueError('Power Off On Reset must be a boolean') spi = config['SPI Config'] @@ -129,7 +122,7 @@ def convert(profile, config): uint(spi['Clock Polarity'], 'CPOL', 1), uint(spi['Word Delay'], 'word delay'), uint(spi['Clock Period Ms'], 'raw clock period'), int(config['Power Off On Reset'] is True), - uint(timeout['Normal Boot Ms'], 'normal timeout', 30000), 0x104] + uint(timeout['Normal Boot Ms'], 'normal timeout', 30000), uint(hbpp_version, 'HBPP version', 0xffff)] first_boot = uint(timeout['First Boot Ms'], 'first timeout', 30000) if not first_boot: raise ValueError('First-boot timeout must be nonzero') @@ -150,7 +143,7 @@ def convert(profile, config): records.append((CALIBRATION, struct.pack(' Path: - spec = json.loads(MANIFEST.read_text()) - root = cache_root() / 'firmware' / PROFILE - root.mkdir(parents=True, exist_ok=True, mode=0o700) - root.chmod(0o700) - expected = {row['name']: row['sha256'] for row in spec['files'] + spec['extracted_inputs']} - locations = {name: root / ('extra' if name in ('t8010-smartio.bin', 'BCM4355C1-FigaroA-USI.hcd') else 'extracted') / name - for name in expected} - if not missing_inputs(locations, expected): - progress.phase('Verified cached firmware') - return root - if not allow_download: - raise ValueError('Verified private firmware cache is missing. Launch does not download an IPSW by default; ' - 'explicitly use --allow-firmware-download to acquire it.') - archive = ensure_archive(spec['ipsw_url'], spec['ipsw_bytes'], spec['ipsw_sha256'], - root / 'archive' / (spec['ipsw_sha256'] + '.ipsw'), - clock, progress, label='Apple IPSW') - executable = ipsw.tool(clock, progress) - progress.phase('Extracting firmware…') - with tempfile.TemporaryDirectory(prefix='extract-', dir=root.parent) as directory: - raw = Path(directory) - ipsw.extract_files(executable, archive, (*spec['extract_patterns'], 'usr/sbin/BlueTool$'), raw) - for name in missing_inputs(locations, expected): - if name == 'BCM4355C1-FigaroA-USI.hcd': - candidates = list(raw.rglob('BlueTool')) - if len(candidates) != 1: - raise ValueError('Expected one extracted BlueTool') - data = ipsw.hcd_from_binary(candidates[0].read_bytes(), expected[name]) - elif name == 't8010-smartio.bin': - data, _ = smartio(ipsw.extract_kernel(executable, archive, spec['device'], raw)) - else: - candidates = [p for p in raw.rglob(name) if p.is_file() and digest(p) == expected[name]] - if len(candidates) != 1: - raise ValueError('Expected one verified extracted firmware input: ' + name) - data = candidates[0].read_bytes() - store_input(locations[name], data, expected[name]) - if missing_inputs(locations, expected): - raise ValueError('Incomplete firmware input cache') - return root diff --git a/src/orchard_cli/j71b/mixin.py b/src/orchard_cli/j71b/mixin.py deleted file mode 100644 index 9f1ddd5..0000000 --- a/src/orchard_cli/j71b/mixin.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Prepare J71b's firmware, then use the shared data-only mixin packer.""" -from pathlib import Path -from typing import Tuple - -from orchard_cli.contracts import MixinReceipt -from orchard_cli.firmware import pack_mixin -from .prepare import prepare, verify -from .config import MANIFEST, PROFILE - - -def build(firmware_dir: Path, outdir: Path) -> Tuple[Path, MixinReceipt]: - if outdir.exists(): - raise ValueError('Choose a new private output directory') - outdir.mkdir(parents=True, mode=0o700) - prepared = prepare(firmware_dir, outdir / 'prepared') - receipt = verify(prepared, MANIFEST) - metadata = {'schema': 1, 'profile': PROFILE, 'files': receipt['files']} - return pack_mixin(prepared, metadata, outdir) diff --git a/src/orchard_cli/j71b/prepare.py b/src/orchard_cli/j71b/prepare.py deleted file mode 100644 index f0cdca2..0000000 --- a/src/orchard_cli/j71b/prepare.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Prepare verified J71b firmware from explicit inputs into an explicit output.""" -from __future__ import annotations - -import json -import os -from pathlib import Path -import tempfile - -from orchard_cli.staging import stage as stage_firmware -from orchard_cli.integrity import digest -from .touch import prepare as prepare_touch - -from .config import MANIFEST, APPLE_PRODUCT - - -def verify(directory: Path, manifest: Path) -> dict: - record = json.loads((directory / 'firmware.json').read_text()) - if (record.get('schema') != 1 or record.get('profile') != 'j71b' or - record.get('manifest_sha256') != digest(manifest)): - raise ValueError('Prepared firmware identity differs') - files = record['files'] - actual = set() - for path in (directory / 'firmware').rglob('*'): - if path.is_symlink(): - raise ValueError('Prepared firmware contains a symlink') - if path.is_file(): - actual.add(path.relative_to(directory / 'firmware').as_posix()) - if actual != set(files): - raise ValueError('Prepared firmware file set differs') - for name, expected in files.items(): - path = directory / 'firmware' / name - if path.stat().st_size != expected['bytes'] or digest(path) != expected['sha256']: - raise ValueError(f'Prepared firmware differs: {name}') - touch = json.loads((directory / 'touch/touch.json').read_text()) - if (touch.get('sha256') != files['lib/firmware/apple/j71b-fwstart.z2fw']['sha256'] or - digest(directory / 'touch/j71b-fwstart.z2fw') != touch['sha256']): - raise ValueError('Touch firmware differs from its receipt') - return record - - -def check_outdir(outdir: Path): - if (outdir.is_symlink() or - (outdir.exists() and (not outdir.is_dir() or any(outdir.iterdir())))): - raise ValueError('Outdir must be absent or an empty directory') - - -def prepare(firmware_dir: Path, outdir: Path) -> Path: - manifest = MANIFEST - spec = json.loads(manifest.read_text()) - if spec.get('device') != APPLE_PRODUCT: - raise ValueError('Wrong J71b firmware manifest') - if not firmware_dir.is_dir() or firmware_dir.is_symlink(): - raise ValueError('Expected a regular firmware input directory') - check_outdir(outdir) - firmware_dir = firmware_dir.resolve() - outdir = outdir.resolve() - if outdir == firmware_dir or firmware_dir in outdir.parents: - raise ValueError('Output must not be inside the firmware inputs') - source = next(item for item in spec['extracted_inputs'] if item['name'] == 'J71b.mtprops') - matches = [path for path in firmware_dir.rglob(source['name']) - if path.is_file() and not path.is_symlink() and digest(path) == source['sha256']] - if len(matches) != 1: - raise ValueError('Expected one verified J71b touch profile in firmware directory') - outdir.parent.mkdir(parents=True, exist_ok=True, mode=0o700) - with tempfile.TemporaryDirectory(prefix='.j71b-', dir=outdir.parent) as temporary: - work = Path(temporary) - touch_dir = work / 'touch' - touch_dir.mkdir() - prepare_touch(matches[0], touch_dir / 'j71b-fwstart.z2fw', touch_dir / 'touch.json') - count = stage_firmware(manifest, [firmware_dir, touch_dir], - [touch_dir / 'touch.json'], work / 'firmware') - expected_count = len(spec['files']) + len(spec['generated']) - if count != expected_count: - raise ValueError('Incomplete J71b firmware stage') - records = {} - for path in sorted((work / 'firmware').rglob('*')): - if path.is_file(): - records[path.relative_to(work / 'firmware').as_posix()] = { - 'bytes': path.stat().st_size, 'sha256': digest(path)} - receipt = {'schema': 1, 'profile': 'j71b', 'manifest_sha256': digest(manifest), 'files': records} - (work / 'firmware.json').write_text(json.dumps(receipt, indent=2) + '\n') - verify(work, manifest) - check_outdir(outdir) - os.replace(work, outdir) - return outdir diff --git a/src/orchard_cli/manifests.py b/src/orchard_cli/manifests.py index fb9686e..09529ee 100644 --- a/src/orchard_cli/manifests.py +++ b/src/orchard_cli/manifests.py @@ -25,6 +25,11 @@ } +def firmware_schema(): + """The firmware recipe schema shipped with the installed hardware data.""" + return json.loads((Path(orchard_hardware.__file__).parent / 'schemas/v1/firmware.schema.json').read_text()) + + class ManifestError(ValueError): pass diff --git a/src/orchard_cli/recipe.py b/src/orchard_cli/recipe.py new file mode 100644 index 0000000..c66c985 --- /dev/null +++ b/src/orchard_cli/recipe.py @@ -0,0 +1,140 @@ +"""Firmware recipes (schema 2): declarative steps from a fixed vocabulary. + +Hardware describes inputs and steps (docs/firmware-recipes.md in hardware); +this module implements the steps. It does not know where inputs come from: +the host reads them from the IPSW, the in-app installer from iPadOS. +""" +import hashlib +import json +import plistlib + +from orchard_cli.apple import ipsw, smartio, z2fw +from orchard_cli.apple.osserialize import apple_xml + + +def _copy(step, data, expected): + return data + + +def _bluetool_hcd(step, data, expected): + return ipsw.hcd_from_binary(data, expected) + + +def _smartio(step, data, expected): + return smartio.extract(data)[0] + + +def _im4p_payload(step, data, expected): + return im4p_payload(data) + + +def _z2fw(step, data, expected): + profiles = plistlib.loads(data) + profile = profiles.get(step['profile']) + if not isinstance(profile, dict): + raise ValueError('Touch profile not found: ' + str(step['profile'])) + output, _ = z2fw.convert(profile, apple_xml(profile['Firmware Config']), + packet=step['packet'], load_address=step['load_address'], + hbpp_version=step['hbpp_version']) + return output + + +# step@version -> (implementation, parameters besides 'step' and 'input') +STEPS = { + 'copy@1': (_copy, set()), + 'bluetool-hcd@1': (_bluetool_hcd, set()), + 'smartio-from-kernelcache@1': (_smartio, set()), + 'im4p-payload@1': (_im4p_payload, set()), + 'z2fw-from-mtprops@1': (_z2fw, {'profile', 'packet', 'load_address', 'hbpp_version'}), +} + + +class UnsupportedRecipe(ValueError): + """The recipe needs a step this Orchard version does not implement.""" + + +def check(recipe): + """Validate the recipe and confirm every step is implemented, before any work.""" + from orchard_cli.manifests import firmware_schema + import jsonschema + jsonschema.validate(recipe, firmware_schema(), cls=jsonschema.Draft202012Validator) + for output in recipe['outputs']: + for step in output['steps']: + if step['step'] not in STEPS: + raise UnsupportedRecipe(f"This firmware recipe needs step {step['step']}; " + 'update Orchard to a version that implements it') + _, parameters = STEPS[step['step']] + if set(step) - {'step', 'input'} != parameters: + raise ValueError(f"Wrong parameters for {step['step']}: {sorted(set(step) - {'step', 'input'})}") + if step['input'] not in recipe['inputs']: + raise ValueError('Unknown recipe input: ' + step['input']) + destinations = [output['destination'] for output in recipe['outputs']] + if len(set(destinations)) != len(destinations): + raise ValueError('Duplicate firmware destination') + return recipe + + +def needed_inputs(recipe, outputs=None): + outputs = recipe['outputs'] if outputs is None else outputs + return sorted({step['input'] for output in outputs for step in output['steps']}) + + +def run(recipe, read_input, outputs=None): + """Produce outputs as {destination: bytes}; read_input(name) returns bytes. + + Every pinned input and every output is checked against its hash. + """ + cache = {} + + def source(name): + if name not in cache: + data = read_input(name) + pinned = recipe['inputs'][name].get('sha256') + if pinned and hashlib.sha256(data).hexdigest() != pinned: + raise ValueError('Firmware input differs from the recipe: ' + name) + cache[name] = data + return cache[name] + + result = {} + for output in recipe['outputs'] if outputs is None else outputs: + data = None + for step in output['steps']: + implementation, _ = STEPS[step['step']] + data = implementation(step, source(step['input']) if data is None else data, output['sha256']) + if hashlib.sha256(data).hexdigest() != output['sha256']: + raise ValueError('Firmware output differs from the recipe: ' + output['destination']) + result[output['destination']] = data + return result + + +def im4p_payload(blob): + """Payload of an IM4P container: SEQUENCE { "IM4P", type, description, OCTET STRING, ... }.""" + def item(pos): + if pos + 2 > len(blob): + raise ValueError('Truncated IM4P') + tag, length = blob[pos], blob[pos + 1] + pos += 2 + if length & 0x80: + count = length & 0x7f + if not 1 <= count <= 4 or pos + count > len(blob): + raise ValueError('Invalid IM4P length') + length = int.from_bytes(blob[pos:pos + count], 'big') + pos += count + if pos + length > len(blob): + raise ValueError('Truncated IM4P') + return tag, blob[pos:pos + length], pos + length + + tag, body, _ = item(0) + if tag != 0x30: + raise ValueError('IM4P is not a DER sequence') + blob = body + fields, pos = [], 0 + while pos < len(blob) and len(fields) < 4: + tag, value, pos = item(pos) + fields.append((tag, value)) + if len(fields) < 4 or fields[0] != (0x16, b'IM4P') or fields[3][0] != 0x04: + raise ValueError('Not an IM4P container') + payload = fields[3][1] + if payload[:4] in (b'bvx2', b'bvx1', b'bvx-', b'bvxn'): + raise ValueError('Compressed IM4P payloads are not supported by im4p-payload@1') + return payload diff --git a/src/orchard_cli/staging.py b/src/orchard_cli/staging.py deleted file mode 100644 index 8ffb366..0000000 --- a/src/orchard_cli/staging.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -"""Stage verified local firmware files using a device manifest.""" -import argparse -import json -from pathlib import Path, PurePosixPath -import re -import shutil - -from .integrity import digest as file_digest - - -def stage(manifest_path: Path, inputs: list[Path], receipt_paths: list[Path], output: Path): - manifest = json.loads(manifest_path.read_text()) - files = manifest['files'] - generated = manifest.get('generated', []) - receipts = {} - for path in receipt_paths: - receipt = json.loads(path.read_text()) - name = receipt.get('name') - digest = receipt.get('sha256') - size = receipt.get('bytes') - if (not isinstance(name, str) or name in receipts or - receipt.get('device') != manifest['device'] or - not isinstance(digest, str) or not re.fullmatch(r'[0-9a-f]{64}', digest) or - isinstance(size, bool) or not isinstance(size, int) or size <= 0): - raise ValueError(f'invalid or duplicate generated-file receipt: {path}') - receipts[name] = receipt - if set(receipts) != {item['name'] for item in generated}: - raise ValueError('supply exactly one local receipt for each generated firmware file') - selected = [] - destinations = set() - names = set() - for item in files + generated: - name = item['name'] - digest = receipts[name]['sha256'] if item in generated else item['sha256'] - size = receipts[name]['bytes'] if item in generated else None - destination = PurePosixPath(item['destination']) - if (not name or name in ('.', '..') or '/' in name or name in names or - destination.is_absolute() or '..' in destination.parts or - not destination.parts or str(destination) in destinations or - not re.fullmatch(r'[0-9a-f]{64}', digest)): - raise ValueError('unsafe or duplicate firmware manifest entry') - destinations.add(str(destination)) - names.add(name) - candidates = (p for directory in inputs - for p in ([directory] if directory.is_file() else directory.rglob(name)) - if p.is_file() and not p.is_symlink() and p.name == name) - matches = [p for p in candidates if (size is None or p.stat().st_size == size) - and file_digest(p) == digest] - if len(matches) != 1: - raise ValueError(f'expected one matching local input for {name}; found {len(matches)}') - selected.append((matches[0], output.joinpath(*destination.parts), digest)) - for _, dest, digest in selected: - if dest.exists() and file_digest(dest) != digest: - raise ValueError(f'existing output differs: {dest}') - for source, dest, digest in selected: - dest.parent.mkdir(parents=True, exist_ok=True) - if not dest.exists(): - shutil.copyfile(source, dest) - dest.chmod(0o600) - if file_digest(dest) != digest: - raise ValueError(f'copied firmware differs from verified input: {dest}') - return len(selected) - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('--manifest', type=Path, required=True) - parser.add_argument('--input', type=Path, action='append', required=True, - help='extracted firmware or local device-data directory; repeat as needed') - parser.add_argument('--receipt', type=Path, action='append', default=[], - help='local receipt from a device-specific generator; repeat as needed') - parser.add_argument('--output', type=Path, required=True) - args = parser.parse_args() - try: - count = stage(args.manifest, args.input, args.receipt, args.output) - except (ValueError, OSError, KeyError) as exc: - parser.error(str(exc)) - print(f'Staged {count} verified local firmware files in {args.output}') - - -if __name__ == '__main__': - main() diff --git a/tests/test_binary_codecs.py b/tests/test_binary_codecs.py index cdf891e..4940423 100644 --- a/tests/test_binary_codecs.py +++ b/tests/test_binary_codecs.py @@ -5,7 +5,7 @@ from orchard_cli.apple.adt_tree import parse, u32s from orchard_cli.apple.macho import macho from orchard_cli.apple.osserialize import apple_xml -from orchard_cli.j71b import touch +from orchard_cli.apple import z2fw as touch def property_record(name, value): diff --git a/tests/test_recipe.py b/tests/test_recipe.py new file mode 100644 index 0000000..608130b --- /dev/null +++ b/tests/test_recipe.py @@ -0,0 +1,69 @@ +"""Firmware recipe steps: vocabulary check, hash verification and containers.""" +import hashlib +import unittest + +from orchard_cli import recipe + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def der(tag, value): + length = len(value) + if length < 0x80: + return bytes([tag, length]) + value + size = (length.bit_length() + 7) // 8 + return bytes([tag, 0x80 | size]) + length.to_bytes(size, 'big') + value + + +def sample(steps, data=b'firmware'): + return { + 'schema': 2, 'device': 'iPad7,5', + 'ipsw': {'version': '17.7.11', 'build': '21H461', 'bytes': 1, 'sha256': '0' * 64, + 'url': 'https://updates.cdn-apple.com/x.ipsw'}, + 'inputs': {'blob': {'ipsw': {'rootfs': 'usr/share/firmware/blob'}, 'sha256': sha(data)}}, + 'outputs': [{'destination': 'lib/firmware/apple/blob', 'sha256': sha(data), 'steps': steps}], + } + + +class RecipeTests(unittest.TestCase): + def test_unknown_step_asks_for_an_update_before_any_work(self): + with self.assertRaisesRegex(recipe.UnsupportedRecipe, 'update Orchard'): + recipe.check(sample([{'step': 'future-format@1', 'input': 'blob'}])) + + def test_wrong_parameters_are_refused(self): + with self.assertRaisesRegex(ValueError, 'Wrong parameters'): + recipe.check(sample([{'step': 'copy@1', 'input': 'blob', 'profile': 'x'}])) + + def test_inputs_and_outputs_are_hash_checked(self): + document = recipe.check(sample([{'step': 'copy@1', 'input': 'blob'}])) + self.assertEqual(recipe.run(document, lambda name: b'firmware'), + {'lib/firmware/apple/blob': b'firmware'}) + with self.assertRaisesRegex(ValueError, 'input differs'): + recipe.run(document, lambda name: b'tampered') + document['inputs']['blob'].pop('sha256') + document['outputs'][0]['sha256'] = '1' * 64 + with self.assertRaisesRegex(ValueError, 'output differs'): + recipe.run(document, lambda name: b'firmware') + + def test_needed_inputs_are_only_those_of_the_requested_outputs(self): + document = recipe.check(sample([{'step': 'copy@1', 'input': 'blob'}])) + self.assertEqual(recipe.needed_inputs(document), ['blob']) + self.assertEqual(recipe.needed_inputs(document, []), []) + + def test_im4p_payload(self): + payload = b'\x7fELF' + bytes(300) + container = der(0x30, der(0x16, b'IM4P') + der(0x16, b'mtfw') + der(0x16, b'touch') + + der(0x04, payload)) + self.assertEqual(recipe.im4p_payload(container), payload) + compressed = der(0x30, der(0x16, b'IM4P') + der(0x16, b'mtfw') + der(0x16, b'touch') + + der(0x04, b'bvx2' + bytes(8))) + with self.assertRaisesRegex(ValueError, 'Compressed'): + recipe.im4p_payload(compressed) + with self.assertRaisesRegex(ValueError, 'Not an IM4P'): + recipe.im4p_payload(der(0x30, der(0x16, b'IMG4') + der(0x16, b'a') + der(0x16, b'b') + der(0x04, b'c'))) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_staging.py b/tests/test_staging.py deleted file mode 100644 index cf075f6..0000000 --- a/tests/test_staging.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Exercise real file hashing for pinned/generated firmware and existing outputs.""" -import hashlib -import json -from pathlib import Path -import tempfile -import unittest - -from orchard_cli.staging import stage - - -class FirmwareStaging(unittest.TestCase): - def test_real_checksums_cover_selection_copy_existing_and_rejection(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - inputs = root / 'inputs' - inputs.mkdir() - manifest = root / 'manifest.json' - receipt = root / 'generated.json' - rows = [] - for name, data in (('pinned.bin',b'pinned'),('generated.bin',b'generated')): - (inputs / name).write_bytes(data) - rows.append({'name':name,'destination':'lib/firmware/'+name, - 'sha256':hashlib.sha256(data).hexdigest()}) - manifest.write_text(json.dumps({'device':'fixture','files':[rows[0]],'generated':[rows[1]]})) - receipt.write_text(json.dumps({'device':'fixture','name':'generated.bin','bytes':9, - 'sha256':rows[1]['sha256']})) - output = root / 'output' - self.assertEqual(stage(manifest,[inputs],[receipt],output),2) - self.assertEqual((output/'lib/firmware/pinned.bin').read_bytes(),b'pinned') - self.assertEqual((output/'lib/firmware/generated.bin').read_bytes(),b'generated') - self.assertEqual(stage(manifest,[inputs],[receipt],output),2) - (output/'lib/firmware/pinned.bin').write_bytes(b'corrupt') - with self.assertRaisesRegex(ValueError,'existing output differs'): - stage(manifest,[inputs],[receipt],output) - (inputs/'pinned.bin').write_bytes(b'corrupt') - with self.assertRaisesRegex(ValueError,'matching local input'): - stage(manifest,[inputs],[receipt],root/'unwritten') - self.assertFalse((root/'unwritten').exists()) - - -if __name__ == '__main__': - unittest.main() From 39f09de7ffc06601406c2e170e8927d886fb0ce1 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 14:56:09 +0800 Subject: [PATCH 06/16] Check recipe steps without the schema for on-device use supported() confirms the recipe format and that every step is implemented with its parameters, without jsonschema or the hardware package, so the in-app installer can run recipes on iPadOS. check() adds schema validation on the host. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/recipe.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/orchard_cli/recipe.py b/src/orchard_cli/recipe.py index c66c985..063b47b 100644 --- a/src/orchard_cli/recipe.py +++ b/src/orchard_cli/recipe.py @@ -54,10 +54,20 @@ class UnsupportedRecipe(ValueError): def check(recipe): - """Validate the recipe and confirm every step is implemented, before any work.""" + """Validate the recipe against the hardware schema, then its steps (host side).""" from orchard_cli.manifests import firmware_schema import jsonschema jsonschema.validate(recipe, firmware_schema(), cls=jsonschema.Draft202012Validator) + return supported(recipe) + + +def supported(recipe): + """Confirm every step is implemented with its parameters, before any work. + + Needs no schema or hardware package, so the in-app installer can use it. + """ + if recipe.get('schema') != 2: + raise UnsupportedRecipe('This firmware recipe format needs a newer Orchard') for output in recipe['outputs']: for step in output['steps']: if step['step'] not in STEPS: From d1acb4421a91bdc7941e3d02334d9c1ec1981d98 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 15:08:41 +0800 Subject: [PATCH 07/16] One generic device adapter driven by hardware's device.toml apple/device.py implements the adapter contract from a device descriptor; devices.py lists every device the installed hardware describes and matches the connected one by DFU chip/board or recovery USB product. Installing requires the descriptor's support.install. The per-device package and SUPPORTED list are gone, and nothing defaults to J71b: projects name their device in orchard.toml, resolve takes --profile. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/apple/device.py | 86 ++++++++++++++++++++++++++++++++ src/orchard_cli/build.py | 12 ++--- src/orchard_cli/command.py | 5 +- src/orchard_cli/devices.py | 50 +++++++++++-------- src/orchard_cli/j71b/__init__.py | 1 - src/orchard_cli/j71b/adapter.py | 81 ------------------------------ src/orchard_cli/j71b/config.py | 11 ---- src/orchard_cli/ram.py | 4 +- src/orchard_cli/service.py | 3 ++ tests/test_adapter_contract.py | 86 +++++++++++++++----------------- tests/test_build_config.py | 15 +++--- tests/test_host_workflow.py | 38 ++++++++------ tests/test_package_boundaries.py | 16 +++--- tests/test_ram.py | 4 +- tests/test_runtime.py | 6 ++- tests/test_shared_build_cache.py | 12 ++--- tests/typecheck_adapter.py | 6 ++- 17 files changed, 228 insertions(+), 208 deletions(-) create mode 100644 src/orchard_cli/apple/device.py delete mode 100644 src/orchard_cli/j71b/__init__.py delete mode 100644 src/orchard_cli/j71b/adapter.py delete mode 100644 src/orchard_cli/j71b/config.py diff --git a/src/orchard_cli/apple/device.py b/src/orchard_cli/apple/device.py new file mode 100644 index 0000000..e9964f4 --- /dev/null +++ b/src/orchard_cli/apple/device.py @@ -0,0 +1,86 @@ +"""Generic Apple device adapter: identity from hardware's device.toml, shared transports. + +Supporting a new device needs only its hardware descriptor and firmware +recipe, not CLI code. +""" +from contextlib import AbstractContextManager +from functools import partial +from typing import Any, Dict, List, Optional, cast + +from orchard_cli.contracts import BootAssets, BootRequest, Connection, SelectedDevice +from orchard_cli.state import BootState, DeviceState +from orchard_cli.progress import Progress +from orchard_cli.apple import usb, boot as apple_boot, adt_capture +from orchard_cli.apple.adt_tree import parse +from orchard_cli.transports import ssh +from orchard_cli import firmware +from orchard_hardware.apple.adt import validate as validate_adt + + +class Device: + def __init__(self, descriptor: Dict[str, Any]): + identity = descriptor['device'] + self.PROFILE = identity['profile'] + self.NAME = identity['name'] + self.APPLE_PRODUCT = identity['apple_products'][0] + # USB product of the device's recovery system; absent until it has one. + self.PRODUCT = descriptor['match'].get('linux', {}).get('product') + self.CHIP = descriptor['match']['dfu']['chip'] + self.BOARD = descriptor['match']['dfu']['board'] + self.INSTALL = bool(descriptor.get('support', {}).get('install', False)) + self.REBOOT_MESSAGE = ('A fresh recovery boot is required.\n' + f'Put the {self.NAME} in DFU mode, then run: orchard install') + self.firmware_inputs = partial(firmware.ensure, self.PROFILE) + self.prepare_mixin = partial(firmware.mixin, self.PROFILE) + + def matches_dfu(self, device: Dict[str, Any]) -> bool: + return (device.get('chip'), device.get('board')) == (self.CHIP, self.BOARD) + + def recovery_endpoint(self) -> Dict[str, Any]: + if self.PRODUCT is None: + raise ValueError(f'The {self.NAME} has no Orchard recovery system yet') + return usb.recovery(self.PRODUCT) + + def connect(self, device: SelectedDevice) -> AbstractContextManager[Connection]: + return ssh.session(DeviceState(self.PROFILE, device['ecid']), self.recovery_endpoint, + expected_boot=device.get('boot_id')) + + def connect_saved(self, device: SelectedDevice) -> AbstractContextManager[Connection]: + if device['mode'] != 'linux' or not device.get('boot_id'): + raise ValueError('Diagnostic access requires the selected running Linux boot') + return ssh.saved_session(DeviceState(self.PROFILE, device['ecid']), self.recovery_endpoint, + expected_boot=device['boot_id']) + + def forget_access(self, scope: BootState) -> None: + if scope.device.profile != self.PROFILE: + raise ValueError('Credential scope differs from adapter profile') + return ssh.forget_access(scope) + + def capture_adt(self, device: Optional[SelectedDevice] = None) -> bytes: + # Bring-up and diagnostics only; installs do not need the ADT. + # DFU/Pongo and Linux location IDs differ: bind the fresh ADT to the ECID. + data = adt_capture.capture(self.recovery_endpoint) + if parse(data).properties.get('model', b'').rstrip(b'\0') != self.APPLE_PRODUCT.encode(): + raise ValueError('Captured ADT is not for ' + self.APPLE_PRODUCT) + if device is not None and validate_adt(data) != device['ecid']: + raise ValueError('Fresh ADT identity differs from the confirmed device') + return data + + def list_devices(self) -> List[Dict[str, Any]]: + return usb.devices() + + def discover(self) -> SelectedDevice: + device = usb.sole_target() + if device['mode'] == 'linux': + endpoint = self.recovery_endpoint() + BootState(DeviceState(self.PROFILE, endpoint['ecid']), endpoint['boot_id']) + return cast(SelectedDevice, {**endpoint, 'resumed': True}) + if not self.matches_dfu(device) or not device['ecid']: + raise ValueError(f'The connected device is not a {self.NAME}') + DeviceState(self.PROFILE, device['ecid']) + return cast(SelectedDevice, {**device, 'resumed': False}) + + def boot(self, device: SelectedDevice, assets: BootAssets, + manifest: BootRequest, progress: Progress) -> None: + return apple_boot.boot(device, assets, manifest, progress, + profile=self.PROFILE, chip=self.CHIP, board=self.BOARD, product=self.PRODUCT) diff --git a/src/orchard_cli/build.py b/src/orchard_cli/build.py index cb4cfdf..5acc5a9 100644 --- a/src/orchard_cli/build.py +++ b/src/orchard_cli/build.py @@ -45,7 +45,7 @@ def configured_device(directory): return device if (parent / '.git').exists(): break - return 'j71b' + return None def digest(path): @@ -196,7 +196,7 @@ def produce_kernel(stage): return directory / 'kernel' -def prepare_kernel(hardware=None, cache=None, *, device='j71b'): +def prepare_kernel(hardware=None, cache=None, *, device): """Build/reuse a coherent kernel, DTB and modules, without boot/tool producers. Defaults to the active hardware package and normal shared Orchard cache. @@ -208,7 +208,7 @@ def prepare_kernel(hardware=None, cache=None, *, device='j71b'): return _prepare_kernel(hardware, cache, device) -def prepare_components(hardware=None, cache=None, *, device='j71b', kernel=None, +def prepare_components(hardware=None, cache=None, *, device, kernel=None, busybox=None, loader=None, pongo=None): """Build/reuse components shared by experiments and larger images. @@ -242,13 +242,13 @@ def produce_pongo(stage): if busybox is None: def produce_tools(stage): work = hardware / '.work/tools' - work = work / recipe_key(hardware, ['boot/initramfs/j71b/recovery', 'buildenv.py']) + work = work / recipe_key(hardware, ['boot/initramfs/recovery', 'buildenv.py']) if not (work / 'bin/busybox').exists(): - command(hardware / 'boot/initramfs/j71b/recovery/build-tools', + command(hardware / 'boot/initramfs/recovery/build-tools', env={**os.environ, 'OMA_RECOVERY_TOOLS_WORK': str(work)}) shutil.copyfile(work / 'bin/busybox', stage / 'busybox') directory = cached_component(components, 'busybox', - lambda: recipe_key(hardware, ['boot/initramfs/j71b/recovery', 'buildenv.py']), produce_tools) + lambda: recipe_key(hardware, ['boot/initramfs/recovery', 'buildenv.py']), produce_tools) busybox = directory / 'busybox' if loader is None: def produce_loader(stage): diff --git a/src/orchard_cli/command.py b/src/orchard_cli/command.py index cf59957..70c8ec1 100644 --- a/src/orchard_cli/command.py +++ b/src/orchard_cli/command.py @@ -49,7 +49,7 @@ def main(): distros.add_argument('source', nargs='?') check = commands.add_parser('resolve', help='verify inputs without accessing a device') check.add_argument('source') - check.add_argument('--profile', default='j71b') + check.add_argument('--profile', required=True) check.add_argument('--boot', action='store_true', help='resolve only boot assets') commands.add_parser('devices', help='list USB devices without modifying them') commands.add_parser('setup-host', help='install missing boot tools and Linux USB permissions; no device access') @@ -113,7 +113,8 @@ def main(): record, assets = resolve(args.source, args.profile, progress, purpose='boot' if args.boot else 'install') result = {'manifest': record, 'files': {key: str(path) for key, path in assets.items()}} elif args.action == 'devices': - result = load().list_devices() + from orchard_cli.apple import usb + result = usb.devices() elif args.action == 'setup-host': from orchard_cli.host_setup import setup result = setup(progress) diff --git a/src/orchard_cli/devices.py b/src/orchard_cli/devices.py index aa8a55f..d40ab3d 100644 --- a/src/orchard_cli/devices.py +++ b/src/orchard_cli/devices.py @@ -1,35 +1,43 @@ -"""Device adapter registry. Shared workflow never interprets hardware identities.""" -import importlib +"""Device registry from the installed hardware data. Shared workflow never interprets hardware identities.""" +from pathlib import Path from typing import List, Optional, Tuple from .contracts import Adapter, SelectedDevice -SUPPORTED = ('j71b',) + +def profiles() -> List[str]: + import orchard_hardware + return orchard_hardware.profiles() def load(profile: Optional[str] = None) -> Adapter: - profile = profile or SUPPORTED[0] - if profile not in SUPPORTED: - raise ValueError('Unsupported device profile') - adapter = importlib.import_module('orchard_cli.' + profile + '.adapter') + """The adapter for a profile, or for the project's configured device.""" + import orchard_hardware + from .apple.device import Device + if profile is None: + from .build import configured_device + profile = configured_device(Path.cwd()) + if profile is None: + raise ValueError('No device selected: add device = "" to orchard.toml') + if profile not in profiles(): + raise ValueError('Hardware support is not installed for ' + profile) + adapter = Device(orchard_hardware.device(profile)) if not isinstance(adapter, Adapter): raise ValueError('Incomplete adapter contract for ' + profile) - if adapter.PROFILE != profile: - raise ValueError('Adapter profile does not match registered profile: ' + profile) - if not isinstance(adapter.NAME, str) or not adapter.NAME or not isinstance(adapter.REBOOT_MESSAGE, str): - raise ValueError('Invalid adapter presentation metadata for ' + profile) return adapter def choose() -> Tuple[Adapter, SelectedDevice]: - matches: List[Tuple[Adapter, SelectedDevice]] = [] - errors: List[str] = [] - for profile in SUPPORTED: - adapter = load(profile) - try: - matches.append((adapter, adapter.discover())) - except ValueError as error: - errors.append(str(error)) + """The one connected device, matched against every described device.""" + from .apple import usb + device = usb.sole_target() + adapters = [load(profile) for profile in profiles()] + if device['mode'] == 'linux': + matches = [a for a in adapters if a.PRODUCT and device.get('product') == a.PRODUCT] + else: + matches = [a for a in adapters if a.matches_dfu(device)] if len(matches) != 1: - raise ValueError('; '.join(errors) or 'Connect exactly one supported device') - return matches[0] + raise ValueError('The connected device is not one Orchard supports ' + f"(chip {device.get('chip')}, board {device.get('board')})" + if not matches else 'The connected device matches several device descriptions') + return matches[0], matches[0].discover() diff --git a/src/orchard_cli/j71b/__init__.py b/src/orchard_cli/j71b/__init__.py deleted file mode 100644 index 7c7d809..0000000 --- a/src/orchard_cli/j71b/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""J71b-specific installer inputs and validation.""" diff --git a/src/orchard_cli/j71b/adapter.py b/src/orchard_cli/j71b/adapter.py deleted file mode 100644 index d77a3f8..0000000 --- a/src/orchard_cli/j71b/adapter.py +++ /dev/null @@ -1,81 +0,0 @@ -"""J71b identity/recipe policy binding the shared Apple boot and transport code.""" -from contextlib import AbstractContextManager -from typing import Any, Dict, List, Optional, cast - -from orchard_cli.contracts import BootAssets, BootRequest, Connection, SelectedDevice -from orchard_cli.state import BootState, DeviceState, EndpointUnavailable -from orchard_cli.progress import Progress -from orchard_cli.apple import usb, boot as apple_boot, adt_capture -from orchard_hardware.apple.adt import validate as validate_adt -from orchard_cli.apple.adt_tree import parse -from orchard_cli.transports import ssh -from functools import partial -from orchard_cli import firmware -from .config import PROFILE, NAME, PRODUCT, CHIP, BOARD, APPLE_PRODUCT - -firmware_inputs = partial(firmware.ensure, PROFILE) -prepare_mixin = partial(firmware.mixin, PROFILE) - -REBOOT_MESSAGE = 'A fresh recovery boot is required.\nPut the iPad in DFU mode, then run: orchard install' - - -def recovery_endpoint() -> Dict[str, Any]: - return usb.recovery(PRODUCT) - - -def connect(device: SelectedDevice) -> AbstractContextManager[Connection]: - return ssh.session(DeviceState(PROFILE, device['ecid']), recovery_endpoint, - expected_boot=device.get('boot_id')) - - -def connect_saved(device: SelectedDevice) -> AbstractContextManager[Connection]: - if device['mode'] != 'linux' or not device.get('boot_id'): - raise ValueError('Diagnostic access requires the selected running Linux boot') - return ssh.saved_session(DeviceState(PROFILE, device['ecid']), recovery_endpoint, - expected_boot=device['boot_id']) - - -def forget_access(scope: BootState) -> None: - if scope.device.profile != PROFILE: - raise ValueError('Credential scope differs from adapter profile') - return ssh.forget_access(scope) - - -def read_adt() -> bytes: - data = adt_capture.capture(recovery_endpoint) - if parse(data).properties.get('model', b'').rstrip(b'\0') != APPLE_PRODUCT.encode(): - raise ValueError('Captured ADT is not for ' + APPLE_PRODUCT) - return data - - -def capture_adt(device: Optional[SelectedDevice] = None) -> bytes: - # DFU/Pongo and Linux location IDs differ: bind the fresh ADT to the ECID. - data = read_adt() - if device is not None and validate_adt(data) != device['ecid']: - raise ValueError('Fresh ADT identity differs from the confirmed device') - return data - - -def list_devices() -> List[Dict[str, Any]]: - return usb.devices() - - -def discover() -> SelectedDevice: - try: - device = usb.sole_target() - except EndpointUnavailable as error: - raise ValueError(str(error)) from error - if device['mode'] == 'linux': - endpoint = recovery_endpoint() - BootState(DeviceState(PROFILE, endpoint['ecid']), endpoint['boot_id']) - return cast(SelectedDevice, {**endpoint, 'resumed': True}) - if (device['chip'], device['board']) != (CHIP, BOARD) or not device['ecid']: - raise ValueError('Unsupported device; only iPad 6 (Wi-Fi), J71b is supported') - DeviceState(PROFILE, device['ecid']) - return cast(SelectedDevice, {**device, 'resumed': False}) - - -def boot(device: SelectedDevice, assets: BootAssets, - manifest: BootRequest, progress: Progress) -> None: - return apple_boot.boot(device, assets, manifest, progress, - profile=PROFILE, chip=CHIP, board=BOARD, product=PRODUCT) diff --git a/src/orchard_cli/j71b/config.py b/src/orchard_cli/j71b/config.py deleted file mode 100644 index 59f4470..0000000 --- a/src/orchard_cli/j71b/config.py +++ /dev/null @@ -1,11 +0,0 @@ -"""J71b facts from the independently installed hardware data package.""" -from orchard_hardware import device, firmware_path - -DEVICE = device('j71b') -PROFILE = DEVICE['device']['profile'] -NAME = DEVICE['device']['name'] -PRODUCT = DEVICE['match']['linux']['product'] -CHIP = DEVICE['match']['dfu']['chip'] -BOARD = DEVICE['match']['dfu']['board'] -APPLE_PRODUCT = DEVICE['device']['apple_products'][0] -MANIFEST = firmware_path(PROFILE) diff --git a/src/orchard_cli/ram.py b/src/orchard_cli/ram.py index 89b77f9..48fca8d 100644 --- a/src/orchard_cli/ram.py +++ b/src/orchard_cli/ram.py @@ -30,7 +30,7 @@ def check_busybox(path): def assemble_ram(hardware, output, *, kernel, busybox, loader, pongo, init, - files=None, hostname='orchard-ram', device='j71b', modules=False, + files=None, hostname='orchard-ram', device, modules=False, bootargs='console=tty0 loglevel=7 rdinit=/init', sources=()): """Pack a caller-owned /init and arbitrary files. No firmware or modules are activated here. Files map relative RAM-root destinations to local files. @@ -99,6 +99,8 @@ def build_directory(directory, *, hardware=None, cache=None, kernel=None, """Build any DIRECTORY/init plus optional DIRECTORY/rootfs/; no layout convention.""" directory = Path(directory).expanduser().resolve() device = device if device is not None else build.configured_device(directory) + if device is None: + raise ValueError('No device selected: add device = "" to orchard.toml') if hardware is None: import orchard_hardware hardware = Path(orchard_hardware.__file__).resolve().parent diff --git a/src/orchard_cli/service.py b/src/orchard_cli/service.py index 4b8614c..d55bdb9 100644 --- a/src/orchard_cli/service.py +++ b/src/orchard_cli/service.py @@ -209,6 +209,9 @@ def finish_ssh_access(client, adapter: Adapter, status, report, path, clock, inp def install(manifest=None, *, input_fn=input, progress=None): clock, progress = Clock(), progress or Progress() adapter, device = choose() # Definition of installation timing begins here. + if not adapter.INSTALL: + raise ValueError(f'Installing on the {adapter.NAME} is not qualified yet; ' + 'RAM boot is available for testing') answer = ask(clock, f'Install Linux on {adapter.NAME}? [Y/n] ', input_fn) if answer.lower() not in ('', 'y', 'yes'): progress.phase('Cancelled; no download or device mutation performed') diff --git a/tests/test_adapter_contract.py b/tests/test_adapter_contract.py index 85507b3..a88b5ae 100644 --- a/tests/test_adapter_contract.py +++ b/tests/test_adapter_contract.py @@ -1,63 +1,59 @@ """Registry/adapter conformance checks, with no USB access or boot actions.""" -from types import SimpleNamespace import unittest -from unittest.mock import Mock, patch +from unittest.mock import patch from orchard_cli.contracts import Adapter from orchard_cli import devices -from orchard_cli.j71b import adapter - -MEMBERS = ('PROFILE', 'NAME', 'REBOOT_MESSAGE', 'list_devices', 'discover', 'boot', - 'connect', 'connect_saved', 'forget_access', 'capture_adt', 'firmware_inputs', 'prepare_mixin') - - -def module(**changes): - values = {name:getattr(adapter,name) for name in MEMBERS} - values.update(changes) - return SimpleNamespace(**values) +from orchard_cli.apple import device as adapter_module class AdapterContract(unittest.TestCase): - def test_registered_module_satisfies_the_contract_without_discovery(self): - with patch.object(adapter, 'discover', side_effect=AssertionError('Unexpected USB access')): - self.assertIs(devices.load('j71b'), adapter) - self.assertIsInstance(adapter, Adapter) - - def test_missing_operation_is_rejected_not_filled_by_a_fallback(self): - incomplete = module() - del incomplete.connect_saved - with patch.object(devices.importlib, 'import_module', return_value=incomplete): - with self.assertRaisesRegex(ValueError, 'Incomplete adapter contract'): - devices.load('j71b') - - def test_registry_profile_mismatch_is_rejected(self): - with patch.object(devices.importlib, 'import_module', return_value=module(PROFILE='other')): - with self.assertRaisesRegex(ValueError, 'does not match'): - devices.load('j71b') - - def test_invalid_presentation_metadata_is_rejected(self): - for values in ({'NAME':''}, {'NAME':None}, {'REBOOT_MESSAGE':None}): - with patch.object(devices.importlib, 'import_module', return_value=module(**values)): - with self.assertRaisesRegex(ValueError, 'Invalid adapter presentation'): - devices.load('j71b') + def test_every_described_device_satisfies_the_contract_without_discovery(self): + self.assertIn('j71b', devices.profiles()) + with patch.object(adapter_module.usb, 'devices', side_effect=AssertionError('Unexpected USB access')): + for profile in devices.profiles(): + with self.subTest(profile=profile): + adapter = devices.load(profile) + self.assertIsInstance(adapter, Adapter) + self.assertEqual(adapter.PROFILE, profile) + self.assertTrue(adapter.NAME) + self.assertIn(adapter.NAME, adapter.REBOOT_MESSAGE) + + def test_identity_comes_from_the_hardware_descriptor(self): + adapter = devices.load('j71b') + self.assertEqual((adapter.CHIP, adapter.BOARD, adapter.PRODUCT), (0x8010, 0x18, 'J71b Recovery')) + self.assertTrue(adapter.INSTALL) + + def test_unknown_profile_is_refused(self): + with self.assertRaisesRegex(ValueError, 'not installed'): + devices.load('no-such-board') def test_dfu_discovery_returns_explicit_resume_state_without_mutating_observation(self): - observed = {'mode':'dfu', 'location':'usb', 'ecid':1, - 'chip':adapter.CHIP, 'board':adapter.BOARD} - with patch.object(adapter.usb, 'devices', return_value=[observed]): + adapter = devices.load('j71b') + observed = {'mode': 'dfu', 'location': 'usb', 'ecid': 1, + 'chip': adapter.CHIP, 'board': adapter.BOARD} + with patch.object(adapter_module.usb, 'devices', return_value=[observed]): selected = adapter.discover() self.assertIs(selected['resumed'], False) self.assertEqual(selected['ecid'], 1) self.assertNotIn('resumed', observed) - def test_choose_passes_the_validated_selection_through_unchanged(self): - selection = {'mode':'dfu','location':'usb','ecid':1,'resumed':False} - implementation = module(discover=Mock(return_value=selection)) - with patch.object(devices, 'load', return_value=implementation): + def test_choose_matches_the_connected_device_against_every_descriptor(self): + adapter = devices.load('j71b') + observed = {'mode': 'dfu', 'location': 'usb', 'ecid': 1, + 'chip': adapter.CHIP, 'board': adapter.BOARD} + with patch.object(adapter_module.usb, 'devices', return_value=[observed]), \ + patch('orchard_cli.apple.usb.devices', return_value=[observed]): loaded, selected = devices.choose() - self.assertIs(loaded, implementation) - self.assertIs(selected, selection) - implementation.discover.assert_called_once_with() + self.assertEqual(loaded.PROFILE, 'j71b') + self.assertEqual(selected['ecid'], 1) + + def test_unknown_connected_device_is_named_in_the_error(self): + observed = {'mode': 'dfu', 'location': 'usb', 'ecid': 1, 'chip': 0x8999, 'board': 0x7f} + with patch('orchard_cli.apple.usb.devices', return_value=[observed]): + with self.assertRaisesRegex(ValueError, 'not one Orchard supports'): + devices.choose() -if __name__ == '__main__':unittest.main() +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_build_config.py b/tests/test_build_config.py index 1edf697..eb4eaf6 100644 --- a/tests/test_build_config.py +++ b/tests/test_build_config.py @@ -6,7 +6,10 @@ from unittest.mock import Mock, patch from orchard_cli import build, service -from orchard_cli.j71b import adapter +from orchard_cli import devices +from orchard_cli.apple import device as adapter_module + +adapter = devices.load('j71b') class BuildConfigTests(unittest.TestCase): @@ -28,14 +31,14 @@ def test_nearest_config_overrides_repository_default(self): (self.example / 'orchard.toml').write_text('device = "experimental"\n') self.assertEqual(build.configured_device(self.example), 'experimental') - def test_no_config_keeps_existing_default(self): + def test_no_config_selects_no_device(self): (self.root / '.git').mkdir() - self.assertEqual(build.configured_device(self.example), 'j71b') + self.assertIsNone(build.configured_device(self.example)) def test_does_not_inherit_another_repositorys_config(self): self.config.write_text('device = "unrelated"\n') (self.example / '.git').write_text('gitdir: elsewhere\n') - self.assertEqual(build.configured_device(self.example), 'j71b') + self.assertIsNone(build.configured_device(self.example)) def test_invalid_or_unused_settings_fail_instead_of_silently_defaulting(self): for content in ('', 'device = [', 'device = 123', 'device = "../escape"', @@ -52,13 +55,13 @@ def test_normal_installer_still_rejects_chip_and_board_mismatches(self): ecid=123, location='fake') observed.update(changes) with self.subTest(mode=mode, changes=changes), \ - patch.object(adapter.usb, 'devices', return_value=[observed]), \ + patch.object(adapter_module.usb, 'devices', return_value=[observed]), \ patch('orchard_cli.state.operation_lock', return_value=nullcontext()), \ patch.object(build, 'configured_device', side_effect=AssertionError('installer read build config')), \ patch.object(service, 'ask') as prompt, \ patch.object(service, 'resolve') as download, \ patch.object(adapter, 'boot') as boot, \ - self.assertRaisesRegex(ValueError, 'Unsupported device'): + self.assertRaisesRegex(ValueError, 'not one Orchard supports'): service.install('unused-manifest', progress=Mock()) prompt.assert_not_called() download.assert_not_called() diff --git a/tests/test_host_workflow.py b/tests/test_host_workflow.py index aa811a6..f4672c0 100644 --- a/tests/test_host_workflow.py +++ b/tests/test_host_workflow.py @@ -8,7 +8,7 @@ import sys import tempfile import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch from orchard_cli.contracts import Connection from orchard_cli.state import BootState, DeviceState @@ -275,40 +275,46 @@ def isatty(self): self.assertEqual(text.count('\n'), 2) def test_recovery_detection_does_not_require_the_dfu_location(self): - from orchard_cli.j71b import adapter + from orchard_cli import devices + from orchard_cli.apple import device as adapter_module + adapter = devices.load('j71b') endpoint = {'mode': 'linux', 'location': 'new-enumeration', 'product': 'J71b Recovery', 'serial_port': '/dev/recovery', 'ecid': 1, 'boot_id': BOOT} original = {'location': 'dfu-enumeration', 'ecid': 1} - with patch.object(adapter.usb, 'devices', return_value=[endpoint]): - self.assertEqual(adapter.apple_boot.wait_mode(original, 'linux', chip=adapter.CHIP, + with patch.object(adapter_module.usb, 'devices', return_value=[endpoint]): + self.assertEqual(adapter_module.apple_boot.wait_mode(original, 'linux', chip=adapter.CHIP, board=adapter.BOARD, product=adapter.PRODUCT, seconds=0), endpoint) - with patch.object(adapter, 'read_adt', return_value=b'fresh'), \ - patch.object(adapter, 'validate_adt', return_value=2): + with patch.object(adapter_module.adt_capture, 'capture', return_value=b'fresh'), patch.object(adapter_module, 'parse', return_value=Mock(properties={'model': b'iPad7,5\0'})), \ + patch.object(adapter_module, 'validate_adt', return_value=2): with self.assertRaisesRegex(ValueError, 'identity differs'): adapter.capture_adt(original) def test_recovery_detection_refuses_ambiguous_or_wrong_endpoints(self): - from orchard_cli.j71b import adapter + from orchard_cli import devices + from orchard_cli.apple import device as adapter_module + adapter = devices.load('j71b') endpoint = {'mode': 'linux', 'location': 'usb', 'product': 'J71b Recovery', 'serial_port': '/dev/recovery', 'ecid': 1, 'boot_id': BOOT} - with patch.object(adapter.usb, 'devices', return_value=[endpoint, {**endpoint, 'location': 'other'}]): + with patch.object(adapter_module.usb, 'devices', return_value=[endpoint, {**endpoint, 'location': 'other'}]): with self.assertRaisesRegex(ValueError, 'exactly one target'): - adapter.apple_boot.wait_mode({'location': 'usb'}, 'linux', chip=adapter.CHIP, + adapter_module.apple_boot.wait_mode({'location': 'usb'}, 'linux', chip=adapter.CHIP, board=adapter.BOARD, product=adapter.PRODUCT, seconds=0) - with patch.object(adapter.usb, 'devices', return_value=[{**endpoint, 'product': 'Other device'}]): + with patch.object(adapter_module.usb, 'devices', return_value=[{**endpoint, 'product': 'Other device'}]): with self.assertRaisesRegex(RuntimeError, 'observed: linux \\(Other device\\)'): - adapter.apple_boot.wait_mode({'location': 'usb'}, 'linux', chip=adapter.CHIP, + adapter_module.apple_boot.wait_mode({'location': 'usb'}, 'linux', chip=adapter.CHIP, board=adapter.BOARD, product=adapter.PRODUCT, seconds=0) def test_running_recovery_can_be_identified_without_reboot(self): - from orchard_cli.j71b import adapter + from orchard_cli import devices + from orchard_cli.apple import device as adapter_module + adapter = devices.load('j71b') from unittest.mock import Mock endpoint = {'mode': 'linux', 'location': 'usb', 'product': 'J71b Recovery', 'serial_port': '/dev/recovery', 'ecid': 1, 'boot_id': BOOT} - with patch.object(adapter.usb, 'devices', return_value=[endpoint]), \ - patch.object(adapter, 'read_adt', return_value=b'fresh'), \ - patch.object(adapter, 'validate_adt', return_value=1), \ - patch.object(adapter.apple_boot.pongo, 'upload') as upload: + with patch.object(adapter_module.usb, 'devices', return_value=[endpoint]), \ + patch.object(adapter_module.adt_capture, 'capture', return_value=b'fresh'), patch.object(adapter_module, 'parse', return_value=Mock(properties={'model': b'iPad7,5\0'})), \ + patch.object(adapter_module, 'validate_adt', return_value=1), \ + patch.object(adapter_module.apple_boot.pongo, 'upload') as upload: device = adapter.discover() self.assertTrue(device['resumed']) self.assertEqual(device['ecid'], 1) diff --git a/tests/test_package_boundaries.py b/tests/test_package_boundaries.py index b6de31c..c7caee5 100644 --- a/tests/test_package_boundaries.py +++ b/tests/test_package_boundaries.py @@ -23,8 +23,8 @@ class PackageBoundaries(unittest.TestCase): def test_removed_board_local_modules_are_not_shipped(self): package=Path(orchard_cli.__file__).parent self.assertFalse((package/'apple/adt.py').exists()) - for name in ('range_download','ssh','usb','linux_usb','adt','adt_tree','adt_capture'): - self.assertFalse((package/'j71b'/(name+'.py')).exists(), name) + # Devices are hardware data; the CLI ships no per-device package. + self.assertFalse((package/'j71b').exists()) def test_shared_imports_and_offline_commands_do_not_import_j71b(self): script = ''' @@ -104,12 +104,14 @@ def test_apple_recovery_selection_is_product_scoped_and_unambiguous(self): with patch.object(usb, 'devices', return_value=[device,{**device,'location':'other'}]): with self.assertRaises(ValueError):usb.recovery('Another Recovery') - def test_j71b_retains_its_model_gate_after_shared_capture(self): - from orchard_cli.j71b import adapter - with patch.object(adapter.adt_capture,'capture',return_value=b'fresh'), \ - patch.object(adapter,'parse',return_value=Mock(properties={'model':b'different\0'})): + def test_adt_capture_keeps_its_model_gate(self): + from orchard_cli import devices + from orchard_cli.apple import device as adapter_module + adapter = devices.load('j71b') + with patch.object(adapter_module.adt_capture,'capture',return_value=b'fresh'), \ + patch.object(adapter_module,'parse',return_value=Mock(properties={'model':b'different\0'})): with self.assertRaisesRegex(ValueError,'Captured ADT is not for'): - adapter.read_adt() + adapter.capture_adt() def test_cached_firmware_archive_is_verified_without_download(self): with tempfile.TemporaryDirectory() as directory: diff --git a/tests/test_ram.py b/tests/test_ram.py index e48e173..3cde6ae 100644 --- a/tests/test_ram.py +++ b/tests/test_ram.py @@ -19,6 +19,8 @@ def setUp(self): temporary = tempfile.TemporaryDirectory() self.addCleanup(temporary.cleanup) self.base = Path(temporary.name).resolve() + # Projects name their device; nothing defaults to one. + (self.base / 'orchard.toml').write_text('device = "j71b"\n') self.hardware = Path(orchard_hardware.__file__).resolve().parent self.kernel = self.base / 'kernel' modules = self.kernel / 'lib/modules/test-release' @@ -49,7 +51,7 @@ def setUp(self): loader=self.loader, pongo=self.pongo) def build(self, **kwargs): - options = dict(init=self.init, files={'usr/bin/test': self.script}, modules=True) + options = dict(init=self.init, files={'usr/bin/test': self.script}, modules=True, device='j71b') options.update(kwargs) return ram.assemble_ram(self.hardware, self.output, **self.components, **options) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 0f6b0e1..90f59a1 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -35,14 +35,16 @@ def tearDown(self): self.temporary.cleanup() def test_adapter_boot_sequence_timeouts_and_no_retry_are_preserved(self): - from orchard_cli.j71b import adapter + from orchard_cli import devices + from orchard_cli.apple import device as adapter_module + adapter = devices.load('j71b') from orchard_cli.apple import boot device = {'mode': 'dfu', 'location': 'selected-port', 'ecid': 123} assets = {'pongo': self.root / 'pongo', 'recovery': self.root / 'boot'} record = {'assets': {'recovery': {'sha256': 'expected-digest'}}} with patch('orchard_cli.state.cache_root', return_value=self.root), \ patch.object(boot.shutil, 'which', return_value='/tools/palera1n'), \ - patch.object(adapter.usb, 'selected') as selected, \ + patch.object(adapter_module.usb, 'selected') as selected, \ patch.object(boot, 'wait_mode') as wait, \ patch.object(boot.subprocess, 'run') as bootstrap, \ patch.object(boot.pongo, 'upload') as upload: diff --git a/tests/test_shared_build_cache.py b/tests/test_shared_build_cache.py index 07383cf..92ecfa0 100644 --- a/tests/test_shared_build_cache.py +++ b/tests/test_shared_build_cache.py @@ -83,7 +83,7 @@ def prepare(self, hardware): with patch.object(kernel, 'build_base', side_effect=self.produce), \ patch.object(kernel, 'build_device', side_effect=lambda hw, cache, device, base, out: self.produce(hw, cache, device, out, base)): - return build.prepare_components(hardware, self.cache, **self.overrides) + return build.prepare_components(hardware, self.cache, device='j71b', **self.overrides) def test_fresh_second_checkout_neither_downloads_checks_out_nor_compiles_linux(self): first = self.prepare(self.one) @@ -102,7 +102,7 @@ def test_kernel_only_caller_reuses_ram_build_without_checkout_or_other_producers patch.object(kernel, 'build_device', side_effect=AssertionError('modules rebuilt')), \ patch.object(sources, 'checkout', side_effect=AssertionError('source checked out')), \ patch.object(build, 'command', side_effect=AssertionError('unrelated producer invoked')): - second = build.prepare_kernel(self.two, self.cache) + second = build.prepare_kernel(self.two, self.cache, device='j71b') self.assertEqual(first['kernel'], second) self.assertFalse((self.two / 'kernel/.work').exists()) @@ -110,7 +110,7 @@ def test_ram_build_reuses_kernel_only_build(self): with patch.object(kernel, 'build_base', side_effect=self.produce), \ patch.object(kernel, 'build_device', side_effect=lambda hw, cache, device, base, out: self.produce(hw, cache, device, out, base)): - first = build.prepare_kernel(self.one, self.cache) + first = build.prepare_kernel(self.one, self.cache, device='j71b') second = self.prepare(self.two) self.assertEqual(first, second['kernel']) self.assertEqual(self.calls, ['kernel-base', 'external-modules-and-dtb']) @@ -123,7 +123,7 @@ def test_kernel_defaults_to_active_hardware_and_shared_cache_environment(self): patch.dict(os.environ, {'ORCHARD_CACHE_DIR': str(self.cache)}), \ patch.object(kernel, 'build_base', side_effect=AssertionError('base rebuilt')), \ patch.object(kernel, 'build_device', side_effect=AssertionError('modules rebuilt')): - second = build.prepare_kernel() + second = build.prepare_kernel(device='j71b') self.assertEqual(first['kernel'], second) self.assertFalse((self.two / 'kernel/.work').exists()) @@ -133,7 +133,7 @@ def test_cache_misses_and_hits_are_visible_without_changing_identity(self): self.assertIn('Building kernel-base-apple (cache miss):', first_log.getvalue()) self.assertIn('Building kernel-j71b (cache miss):', first_log.getvalue()) with redirect_stdout(io.StringIO()) as second_log: - second = build.prepare_kernel(self.two, self.cache) + second = build.prepare_kernel(self.two, self.cache, device='j71b') self.assertEqual(first['kernel'], second) self.assertIn('Reusing kernel-base-apple:', second_log.getvalue()) self.assertIn('Reusing kernel-j71b:', second_log.getvalue()) @@ -147,7 +147,7 @@ def test_reused_cache_is_still_hash_verified(self): shutil.rmtree(first['kernel'].parent) with patch.object(kernel, 'build_base', side_effect=AssertionError('silently rebuilt')), \ self.assertRaisesRegex(ValueError, 'Cached component changed'): - build.prepare_kernel(self.two, self.cache) + build.prepare_kernel(self.two, self.cache, device='j71b') def test_missing_recipe_fails_before_any_checkout_or_producer(self): with patch.object(sources, 'checkout', side_effect=AssertionError('checked out source')), \ diff --git a/tests/typecheck_adapter.py b/tests/typecheck_adapter.py index 8e0e328..9b858e1 100644 --- a/tests/typecheck_adapter.py +++ b/tests/typecheck_adapter.py @@ -1,5 +1,7 @@ """Static conformance check: the registered module must satisfy Adapter.""" from orchard_cli.contracts import Adapter -from orchard_cli.j71b import adapter +from orchard_cli.apple.device import Device -J71B: Adapter = adapter + +def check(device: Device) -> Adapter: + return device From 7297302a7d1ce148f5e6f4ad7bcfc2628077b223 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 15:22:42 +0800 Subject: [PATCH 08/16] Show device status and limitations before installing The installer names the device and its status and lists its limitations. For experimental devices it warns that the device has not been tested yet and defaults to No; supported devices keep Yes as the default. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/apple/device.py | 5 ++++- src/orchard_cli/service.py | 13 ++++++++++--- tests/test_host_workflow.py | 24 +++++++++++++++++++++--- 3 files changed, 35 insertions(+), 7 deletions(-) diff --git a/src/orchard_cli/apple/device.py b/src/orchard_cli/apple/device.py index e9964f4..043f410 100644 --- a/src/orchard_cli/apple/device.py +++ b/src/orchard_cli/apple/device.py @@ -27,7 +27,10 @@ def __init__(self, descriptor: Dict[str, Any]): self.PRODUCT = descriptor['match'].get('linux', {}).get('product') self.CHIP = descriptor['match']['dfu']['chip'] self.BOARD = descriptor['match']['dfu']['board'] - self.INSTALL = bool(descriptor.get('support', {}).get('install', False)) + support = descriptor.get('support', {}) + self.INSTALL = bool(support.get('install', False)) + self.STATUS = support.get('status', 'experimental') + self.LIMITATIONS = list(support.get('limitations', [])) self.REBOOT_MESSAGE = ('A fresh recovery boot is required.\n' f'Put the {self.NAME} in DFU mode, then run: orchard install') self.firmware_inputs = partial(firmware.ensure, self.PROFILE) diff --git a/src/orchard_cli/service.py b/src/orchard_cli/service.py index d55bdb9..817e593 100644 --- a/src/orchard_cli/service.py +++ b/src/orchard_cli/service.py @@ -210,10 +210,17 @@ def install(manifest=None, *, input_fn=input, progress=None): clock, progress = Clock(), progress or Progress() adapter, device = choose() # Definition of installation timing begins here. if not adapter.INSTALL: - raise ValueError(f'Installing on the {adapter.NAME} is not qualified yet; ' + raise ValueError(f'Installing on the {adapter.NAME} is not available yet; ' 'RAM boot is available for testing') - answer = ask(clock, f'Install Linux on {adapter.NAME}? [Y/n] ', input_fn) - if answer.lower() not in ('', 'y', 'yes'): + experimental = adapter.STATUS != 'supported' + progress.phase(f'{adapter.NAME} ({adapter.STATUS})') + if adapter.LIMITATIONS: + progress.phase('Limitations:\n' + ''.join(f'- {item}\n' for item in adapter.LIMITATIONS).rstrip()) + if experimental: + progress.phase('This device has not been tested yet, proceed at your own risk!') + prompt = f'Install Linux on {adapter.NAME}? ' + ('[y/N] ' if experimental else '[Y/n] ') + answer = ask(clock, prompt, input_fn) + if answer.lower() not in (('y', 'yes') if experimental else ('', 'y', 'yes')): progress.phase('Cancelled; no download or device mutation performed') return {'cancelled': True} record = assets = None diff --git a/tests/test_host_workflow.py b/tests/test_host_workflow.py index f4672c0..0ea7d11 100644 --- a/tests/test_host_workflow.py +++ b/tests/test_host_workflow.py @@ -31,7 +31,7 @@ def setUp(self): self.addCleanup(self.state_patch.stop) def test_cancel_before_asset_access_download_or_mutation(self): from unittest.mock import Mock - adapter = Mock(NAME='Supported tablet', PROFILE='device') + adapter = Mock(NAME='Supported tablet', PROFILE='device', INSTALL=True, STATUS='supported', LIMITATIONS=[]) with patch('orchard_cli.service.choose', return_value=(adapter, {'mode': 'dfu'})), \ patch('orchard_cli.service.resolve') as assets: result = install(input_fn=lambda _: 'n', progress=Progress(io.StringIO())) @@ -40,6 +40,24 @@ def test_cancel_before_asset_access_download_or_mutation(self): adapter.firmware_inputs.assert_not_called() adapter.boot.assert_not_called() + def test_experimental_device_shows_limitations_and_defaults_to_no(self): + from unittest.mock import Mock + adapter = Mock(NAME='Untested tablet', PROFILE='device', INSTALL=True, STATUS='experimental', + LIMITATIONS=['No touchscreen yet', 'No Wi-Fi yet']) + prompts = [] + with patch('orchard_cli.service.choose', return_value=(adapter, {'mode': 'dfu'})), \ + patch('orchard_cli.service.resolve') as assets: + output = io.StringIO() + result = install(input_fn=lambda text: prompts.append(text) or '', progress=Progress(output)) + self.assertTrue(result['cancelled']) + self.assertEqual(prompts, ['Install Linux on Untested tablet? [y/N] ']) + text = output.getvalue() + self.assertIn('- No touchscreen yet', text) + self.assertIn('- No Wi-Fi yet', text) + self.assertIn('not been tested yet, proceed at your own risk', text) + assets.assert_not_called() + adapter.boot.assert_not_called() + def test_guided_run_reaches_desktop_with_one_mixin_and_one_root_stream(self): for filesystem in ('ext4', 'unformatted'): with self.subTest(filesystem=filesystem): @@ -54,7 +72,7 @@ def guided_run(self, filesystem): image, mixin = root / 'root', root / 'mixin' image.write_bytes(b'root-stream') mixin.write_bytes(b'private-data') - adapter = Mock(NAME='Supported tablet', PROFILE='device') + adapter = Mock(NAME='Supported tablet', PROFILE='device', INSTALL=True, STATUS='supported', LIMITATIONS=[]) adapter.capture_adt.return_value = b'fresh-adt' adapter.prepare_mixin.return_value = (mixin, {'bytes': 12, 'sha256': 'mixin-digest'}) import threading @@ -233,7 +251,7 @@ def test_failed_session_is_cleaned_before_asset_or_mixin_work(self): from contextlib import contextmanager from unittest.mock import Mock with tempfile.TemporaryDirectory() as directory: - adapter = Mock(NAME='Supported tablet', PROFILE='device') + adapter = Mock(NAME='Supported tablet', PROFILE='device', INSTALL=True, STATUS='supported', LIMITATIONS=[]) adapter.REBOOT_MESSAGE = 'A fresh recovery boot is required. Run: orchard install' @contextmanager def connection(device): From 01cb5e030ea39a1102ffebfebd15dd81fd4316dc Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 15:27:30 +0800 Subject: [PATCH 09/16] Show caveats from the feature matrix; default to Yes everywhere The installer lists every feature that is not working, the device's caveats and, for experimental devices, an untested warning, then asks with Yes as the default. Experimental devices need users to try them. Co-Authored-By: Claude Opus 5.5 --- NOTICE | 2 +- src/orchard_cli/apple/device.py | 3 ++- src/orchard_cli/service.py | 25 +++++++++++++++++-------- tests/test_host_workflow.py | 23 +++++++++++++---------- 4 files changed, 33 insertions(+), 20 deletions(-) diff --git a/NOTICE b/NOTICE index 047a2bc..c6a4642 100644 --- a/NOTICE +++ b/NOTICE @@ -1,6 +1,6 @@ Default project license: GPL-2.0-or-later (LICENSE). Pongo/libusb uploader: checkra1n team, MIT (LICENSES/Pongo.txt). -Touch conversion (j71b/touch.py): GPL-2.0-or-later. Packet layouts and Z2FW +Touch conversion (apple/z2fw.py): GPL-2.0-or-later. Packet layouts and Z2FW framing are informed by Corellium's Project Sandcastle and Paul Praschl's (Pauli1Go) makez2fw: https://github.com/Pauli1Go/HoolockLinux-linux-firmware diff --git a/src/orchard_cli/apple/device.py b/src/orchard_cli/apple/device.py index 043f410..96cb0f8 100644 --- a/src/orchard_cli/apple/device.py +++ b/src/orchard_cli/apple/device.py @@ -30,7 +30,8 @@ def __init__(self, descriptor: Dict[str, Any]): support = descriptor.get('support', {}) self.INSTALL = bool(support.get('install', False)) self.STATUS = support.get('status', 'experimental') - self.LIMITATIONS = list(support.get('limitations', [])) + self.CAVEATS = list(support.get('caveats', [])) + self.FEATURES = dict(descriptor.get('features', {})) self.REBOOT_MESSAGE = ('A fresh recovery boot is required.\n' f'Put the {self.NAME} in DFU mode, then run: orchard install') self.firmware_inputs = partial(firmware.ensure, self.PROFILE) diff --git a/src/orchard_cli/service.py b/src/orchard_cli/service.py index 817e593..68bb9c7 100644 --- a/src/orchard_cli/service.py +++ b/src/orchard_cli/service.py @@ -205,6 +205,19 @@ def finish_ssh_access(client, adapter: Adapter, status, report, path, clock, inp progress.phase('SSH access revoked for this boot; cached credentials removed') +def caveats(adapter): + """What to know before installing: features that are not working, free-text caveats.""" + import orchard_hardware + labels = orchard_hardware.features() + states = {'partial': 'partial', 'untested': 'untested', 'missing': 'not working yet'} + rows = [f'- {label}: {states[adapter.FEATURES[name]]}' for name, label in labels.items() + if adapter.FEATURES.get(name) in states] + lines = ['Caveats:', *rows, *(f'- {text}' for text in adapter.CAVEATS)] if rows or adapter.CAVEATS else [] + if adapter.STATUS != 'supported': + lines.append('This device has not been tested yet, proceed at your own risk!') + return lines + + @exclusive_operation def install(manifest=None, *, input_fn=input, progress=None): clock, progress = Clock(), progress or Progress() @@ -212,15 +225,11 @@ def install(manifest=None, *, input_fn=input, progress=None): if not adapter.INSTALL: raise ValueError(f'Installing on the {adapter.NAME} is not available yet; ' 'RAM boot is available for testing') - experimental = adapter.STATUS != 'supported' progress.phase(f'{adapter.NAME} ({adapter.STATUS})') - if adapter.LIMITATIONS: - progress.phase('Limitations:\n' + ''.join(f'- {item}\n' for item in adapter.LIMITATIONS).rstrip()) - if experimental: - progress.phase('This device has not been tested yet, proceed at your own risk!') - prompt = f'Install Linux on {adapter.NAME}? ' + ('[y/N] ' if experimental else '[Y/n] ') - answer = ask(clock, prompt, input_fn) - if answer.lower() not in (('y', 'yes') if experimental else ('', 'y', 'yes')): + for line in caveats(adapter): + progress.phase(line) + answer = ask(clock, f'Install Linux on {adapter.NAME}? [Y/n] ', input_fn) + if answer.lower() not in ('', 'y', 'yes'): progress.phase('Cancelled; no download or device mutation performed') return {'cancelled': True} record = assets = None diff --git a/tests/test_host_workflow.py b/tests/test_host_workflow.py index 0ea7d11..5410038 100644 --- a/tests/test_host_workflow.py +++ b/tests/test_host_workflow.py @@ -31,7 +31,7 @@ def setUp(self): self.addCleanup(self.state_patch.stop) def test_cancel_before_asset_access_download_or_mutation(self): from unittest.mock import Mock - adapter = Mock(NAME='Supported tablet', PROFILE='device', INSTALL=True, STATUS='supported', LIMITATIONS=[]) + adapter = Mock(NAME='Supported tablet', PROFILE='device', INSTALL=True, STATUS='supported', CAVEATS=[], FEATURES={}) with patch('orchard_cli.service.choose', return_value=(adapter, {'mode': 'dfu'})), \ patch('orchard_cli.service.resolve') as assets: result = install(input_fn=lambda _: 'n', progress=Progress(io.StringIO())) @@ -40,23 +40,26 @@ def test_cancel_before_asset_access_download_or_mutation(self): adapter.firmware_inputs.assert_not_called() adapter.boot.assert_not_called() - def test_experimental_device_shows_limitations_and_defaults_to_no(self): + def test_experimental_device_shows_caveats_and_still_defaults_to_yes(self): from unittest.mock import Mock adapter = Mock(NAME='Untested tablet', PROFILE='device', INSTALL=True, STATUS='experimental', - LIMITATIONS=['No touchscreen yet', 'No Wi-Fi yet']) + FEATURES={'touch': 'missing', 'wifi': 'untested', 'display': 'working', 'cellular': 'none'}, + CAVEATS=['Bluetooth needs its module vendor chosen per unit.']) prompts = [] with patch('orchard_cli.service.choose', return_value=(adapter, {'mode': 'dfu'})), \ patch('orchard_cli.service.resolve') as assets: output = io.StringIO() - result = install(input_fn=lambda text: prompts.append(text) or '', progress=Progress(output)) + result = install(input_fn=lambda text: prompts.append(text) or 'n', progress=Progress(output)) self.assertTrue(result['cancelled']) - self.assertEqual(prompts, ['Install Linux on Untested tablet? [y/N] ']) + self.assertEqual(prompts, ['Install Linux on Untested tablet? [Y/n] ']) text = output.getvalue() - self.assertIn('- No touchscreen yet', text) - self.assertIn('- No Wi-Fi yet', text) + self.assertIn('- Touchscreen: not working yet', text) + self.assertIn('- Wi-Fi: untested', text) + self.assertNotIn('Display', text) + self.assertNotIn('Cellular', text) + self.assertIn('- Bluetooth needs its module vendor chosen per unit.', text) self.assertIn('not been tested yet, proceed at your own risk', text) assets.assert_not_called() - adapter.boot.assert_not_called() def test_guided_run_reaches_desktop_with_one_mixin_and_one_root_stream(self): for filesystem in ('ext4', 'unformatted'): @@ -72,7 +75,7 @@ def guided_run(self, filesystem): image, mixin = root / 'root', root / 'mixin' image.write_bytes(b'root-stream') mixin.write_bytes(b'private-data') - adapter = Mock(NAME='Supported tablet', PROFILE='device', INSTALL=True, STATUS='supported', LIMITATIONS=[]) + adapter = Mock(NAME='Supported tablet', PROFILE='device', INSTALL=True, STATUS='supported', CAVEATS=[], FEATURES={}) adapter.capture_adt.return_value = b'fresh-adt' adapter.prepare_mixin.return_value = (mixin, {'bytes': 12, 'sha256': 'mixin-digest'}) import threading @@ -251,7 +254,7 @@ def test_failed_session_is_cleaned_before_asset_or_mixin_work(self): from contextlib import contextmanager from unittest.mock import Mock with tempfile.TemporaryDirectory() as directory: - adapter = Mock(NAME='Supported tablet', PROFILE='device', INSTALL=True, STATUS='supported', LIMITATIONS=[]) + adapter = Mock(NAME='Supported tablet', PROFILE='device', INSTALL=True, STATUS='supported', CAVEATS=[], FEATURES={}) adapter.REBOOT_MESSAGE = 'A fresh recovery boot is required. Run: orchard install' @contextmanager def connection(device): From d798fc23bd4cddb1189a6838183839e00fc9c73d Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 15:27:41 +0800 Subject: [PATCH 10/16] Group caveats by feature state Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/service.py | 9 ++++++--- tests/test_host_workflow.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/orchard_cli/service.py b/src/orchard_cli/service.py index 68bb9c7..7c80f3c 100644 --- a/src/orchard_cli/service.py +++ b/src/orchard_cli/service.py @@ -209,9 +209,12 @@ def caveats(adapter): """What to know before installing: features that are not working, free-text caveats.""" import orchard_hardware labels = orchard_hardware.features() - states = {'partial': 'partial', 'untested': 'untested', 'missing': 'not working yet'} - rows = [f'- {label}: {states[adapter.FEATURES[name]]}' for name, label in labels.items() - if adapter.FEATURES.get(name) in states] + groups = {'missing': 'Not working yet', 'partial': 'Partly working', 'untested': 'Untested'} + rows = [] + for state, title in groups.items(): + names = [label for name, label in labels.items() if adapter.FEATURES.get(name) == state] + if names: + rows.append(f'- {title}: ' + ', '.join(names)) lines = ['Caveats:', *rows, *(f'- {text}' for text in adapter.CAVEATS)] if rows or adapter.CAVEATS else [] if adapter.STATUS != 'supported': lines.append('This device has not been tested yet, proceed at your own risk!') diff --git a/tests/test_host_workflow.py b/tests/test_host_workflow.py index 5410038..15eadc5 100644 --- a/tests/test_host_workflow.py +++ b/tests/test_host_workflow.py @@ -53,8 +53,8 @@ def test_experimental_device_shows_caveats_and_still_defaults_to_yes(self): self.assertTrue(result['cancelled']) self.assertEqual(prompts, ['Install Linux on Untested tablet? [Y/n] ']) text = output.getvalue() - self.assertIn('- Touchscreen: not working yet', text) - self.assertIn('- Wi-Fi: untested', text) + self.assertIn('- Not working yet: Touchscreen', text) + self.assertIn('- Untested: Wi-Fi', text) self.assertNotIn('Display', text) self.assertNotIn('Cellular', text) self.assertIn('- Bluetooth needs its module vendor chosen per unit.', text) From 532e4643ab4eb628fac19cceeb7b92aeaf8129c0 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 15:48:38 +0800 Subject: [PATCH 11/16] Port makez2fw's packet-array and MTFW touch conversions Add two conversions from Paul Praschl's (Pauli1Go) GPL makez2fw beside the J71b one: - convert_packets(): mtprops whose constructed firmware is an array of preconstructed packets (iPhone 7 Plus, D11.mtprops profile C1F5E,2). The output is byte-identical to makez2fw --dynamic-calibration. - convert_mtfw(): an MTFW image listing HBPP load items per personality (iPad 7, J171_/J172_Multitouch.im4p profile K1F19,6), including makez2fw's OTP preflight register reads (Z2FW command 5). It keeps makez2fw's commands, packets, order and configuration words, but emits a calibration record (command 6) for each Property item where makez2fw bakes SysCfg data. Property names without an apple_z2 provider ID are refused. Add apple_z2's orb-gap, orb-force and shape-accel provider IDs, which the iPhone 7 Plus calibration sequence uses. J71b's output is unchanged. Co-Authored-By: Claude Opus 5.5 --- NOTICE | 3 +- src/orchard_cli/apple/z2fw.py | 266 +++++++++++++++++++++++++++++++--- tests/test_z2fw.py | 201 +++++++++++++++++++++++++ 3 files changed, 452 insertions(+), 18 deletions(-) create mode 100644 tests/test_z2fw.py diff --git a/NOTICE b/NOTICE index c6a4642..1410926 100644 --- a/NOTICE +++ b/NOTICE @@ -2,6 +2,7 @@ Default project license: GPL-2.0-or-later (LICENSE). Pongo/libusb uploader: checkra1n team, MIT (LICENSES/Pongo.txt). Touch conversion (apple/z2fw.py): GPL-2.0-or-later. Packet layouts and Z2FW framing are informed by Corellium's Project Sandcastle and Paul Praschl's -(Pauli1Go) makez2fw: +(Pauli1Go) makez2fw. convert_packets() and convert_mtfw() port makez2fw's +Gen2 mtprops and MTFW HBPP item conversions (mtfw.c, makez2fw.c): https://github.com/Pauli1Go/HoolockLinux-linux-firmware Reference revision: 944541e67b4c603df328c77d30093551de802c17. diff --git a/src/orchard_cli/apple/z2fw.py b/src/orchard_cli/apple/z2fw.py index d510309..479ef9a 100644 --- a/src/orchard_cli/apple/z2fw.py +++ b/src/orchard_cli/apple/z2fw.py @@ -1,10 +1,28 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-or-later -"""Convert an Apple multitouch profile (mtprops) to Z2FW with calibration records. +"""Convert Apple multitouch firmware (mtprops, MTFW) to Z2FW with calibration records. The file is the same for every unit of a model: apple_z2 fills each calibration record from the DT properties m1n1 copies from the ADT at boot. +Three input forms: +- convert(): an mtprops profile whose "Constructed Firmware" is one + preconstructed HBPP14 packet (J71b). +- convert_packets(): an mtprops profile whose "Constructed Firmware" is an + array of preconstructed packets (iPhone 7 Plus); makez2fw's Gen2 path. +- convert_mtfw(): an MTFW image (IM4P payload) listing HBPP load items per + personality (iPad 7); makez2fw's HBPP item path. + +convert_packets() and convert_mtfw() are ports of Paul Praschl's (Pauli1Go) +GPL-2.0-or-later makez2fw (mtfw.c, makez2fw.c), which builds on Corellium's +Project Sandcastle. They keep its command order, packets and configuration +words. Per-unit calibration is the one difference: where makez2fw loads +SysCfg data (or the MTFW's placeholder payload) as HBPP blob packets, these +emit calibration records in the same place. For mtprops packet arrays that +is exactly makez2fw --dynamic-calibration. makez2fw reads an MTFW's config +from the first config block in the file; convert_mtfw() reads the selected +personality's own config item. + Packet layouts/Z2FW framing informed by Corellium Project Sandcastle and Pauli1Go's GPL makez2fw (944541e67b4c603df328c77d30093551de802c17). """ @@ -12,9 +30,11 @@ MAGIC = 0x5746325a -INIT, SEND, WAIT, CONFIG, CALIBRATION = 0, 1, 3, 4, 6 -# apple_z2 calibration provider IDs (drivers/input/touchscreen/apple_z2.c). -PROVIDERS = {'multi-touch-calibration': 0, 'multitouch-to-display-offset': 4, +INIT, SEND, WAIT, CONFIG, RAW_XFER, CALIBRATION = 0, 1, 3, 4, 5, 6 +# apple_z2 calibration provider IDs (enum apple_z2_calibration_provider in +# drivers/input/touchscreen/apple_z2.c), keyed by the ADT property name. +PROVIDERS = {'multi-touch-calibration': 0, 'orb-gap-cal': 1, 'orb-force-cal': 2, + 'shape-dynamic-accel-cal': 3, 'multitouch-to-display-offset': 4, 'firefly-calibration': 5, 'pressure-effect-correction': 6} @@ -86,7 +106,7 @@ def decode_container(blob): code, size = struct.unpack_from(' len(blob): + if code not in (INIT, SEND, WAIT, CONFIG, RAW_XFER, CALIBRATION) or next_pos > len(blob): raise ValueError('Unknown or truncated Z2FW command') if any(blob[pos+size:next_pos]): raise ValueError('Nonzero Z2FW padding') @@ -95,6 +115,29 @@ def decode_container(blob): return records +def calibration_record(name, address, maximum, providers): + """Z2FW command 6: apple_z2 sends the DT property of this provider to address. + + providers collects the records so far and rejects duplicates and overlaps. + """ + if name not in PROVIDERS: + raise ValueError('No apple_z2 calibration provider for ' + repr(name)) + if any(item['provider'] == name for item in providers): + raise ValueError('Duplicate calibration provider: ' + name) + maximum = uint(maximum, 'calibration maximum', 0x40000) + if not maximum: + raise ValueError('Empty calibration range: ' + name) + address = uint(address, 'calibration address') + if not address: + raise ValueError('Calibration address must be nonzero: ' + name) + end = address + ((maximum+3)&~3) + if end > 0x100000000 or any(address < item['address'] + ((item['maximum']+3)&~3) and item['address'] < end + for item in providers): + raise ValueError('Overlapping or overflowing calibration ranges') + providers.append({'provider': name, 'maximum': maximum, 'address': address}) + return CALIBRATION, struct.pack(' 0x100000000 or any(address < b and a < end for a, b in addresses): - raise ValueError('Overlapping or overflowing calibration ranges') - addresses.append((address, end)) - records.append((CALIBRATION, struct.pack(' 0x40000 or address + len(padded) > 0x100000000: + raise ValueError('HBPP payload too large') + header = struct.pack('>H', len(padded) // 4 - 1) + xe32(address) + return (b'\x18\xe1\x30\x01' + header + struct.pack('>H', sum(header)) + swapped(padded) + + xe32(sum(data) & 0xffffffff)) + + +def raw_xfer(tx, rx_size, flags=RX_BIT_REVERSE): + """Z2FW command 5 payload: tx length, rx length, flags, then the tx bytes.""" + if len(tx) > 16 or rx_size > 16 or not (tx or rx_size): + raise ValueError('Invalid raw transfer') + return struct.pack('H', sum(fields)) + return [(RAW_XFER, raw_xfer(request, 0)), (RAW_XFER, raw_xfer(HBPP_INIT + b'\x18\xe1' * 2, 8))] + + +def otp_preflight_records(address, index, size): + """makez2fw's OTP read sequence before the first load (mtfw_item_add_otp_preflight).""" + if not address or not size or size & 3 or size > 256: + raise ValueError('Invalid OTP address or selection') + + def write(offset, value): + return SEND, register_packet(address + offset, 0xffffffff, value) + + records = [write(0x00, 2), write(0x04, 0), write(0x08, 0)] + for word in range(size >> 2): + records += [write(0x10, 0xffffffff), write(0x0c, index + word), + write(0x04, 0x00e80000), write(0x04, 0x00e80001)] + records += register_read(address + 0x20) + [write(0x20, 1)] + register_read(address + 0x18) + return records + + +def hbpp_config(config, *, overrides): + """makez2fw's configuration words, valid mask and boot wait. + + Only present fields set their valid bit. overrides reads the MTFW's + "Device Tree Overrides" (makez2fw does so only for MTFW item lists). + Returns (valid, words or None, wait_ms, (otp address, index, size) or None). + """ + if not isinstance(config, dict): + raise ValueError('Unexpected firmware config shape') + valid, values = 0, [0] * 11 + + def section(key): + value = config.get(key, {}) + if not isinstance(value, dict): + raise ValueError('Unexpected config section: ' + key) + return value + + z2, spi, timeout = section('Z2 Config'), section('SPI Config'), section('Boot Timeout') + fields = [(config, 'Min DMA Transfer Size', 0, 1), (z2, 'Inter Packet Delay', 1, 2), + (spi, 'CS Delay', 2, 3), (spi, 'Clock Phase', 3, 4), (spi, 'Clock Polarity', 4, 5), + (spi, 'Word Delay', 5, 6), (spi, 'Clock Period Ms', 6, 7), (timeout, 'Normal Boot Ms', 8, 9)] + for container, key, bit, index in fields: + if key in container: + values[index] = uint(container[key], key) + valid |= 1 << bit + if 'Power Off On Reset' in config: + if not isinstance(config['Power Off On Reset'], bool): + raise ValueError('Power Off On Reset must be a boolean') + values[8] = int(config['Power Off On Reset']) + valid |= 1 << 7 + otp = None + if overrides: + dto = section('Device Tree Overrides') + + def data(key, minimum): + value = dto.get(key) + return value if isinstance(value, bytes) and len(value) >= minimum else None + + otp_address, chip_id, hbpp, otp_sn = (data('otp-address', 4), data('chip-id-address', 4), + data('hbpp-version', 4), data('otp-sn', 8)) + for value, bit in ((otp_address, 9), (chip_id, 10), (hbpp, 11), (otp_sn, 12), + (data('reset-sequence', 1), 13), (data('power-sequence', 1), 14)): + if value is not None: + valid |= 1 << bit + if hbpp is not None: + values[10] = struct.unpack_from('SysConfigKeyMtClPayloadAAAAAAAAAAA=' + 'Propertymulti-touch-calibrationMaxSize' + '0x600Address0x100400' + 'TypeProperty') if calibration else '' + return ( + 'TEST,6' + 'Address0x400b0000Mask' + '0x1TypeReadModifyWrite' + 'Value0x0SkipCheck' + 'Address0x100000Payload' + f'{b64(bytes(range(1, 11)))}TypeBinary' + + prop + + 'AddressType' + 'Value0x1' + 'TypeConfigConfig' + 'ProtocolZ2Z2 ConfigInter Packet Delay' + '125Min DMA Transfer Size17' + 'SPI ConfigClock Phase1Word Delay' + '0Clock Polarity1' + 'CS Delay10000Clock Period Ms' + '41Power Off On Reset' + 'Device Tree Overrides' + f'otp-address{b64(struct.pack("' + f'hbpp-version{b64(struct.pack("' + f'otp-sn{b64(struct.pack("' + 'Boot TimeoutNormal Boot Ms500' + '').encode() + b'\0' + + +def mtprops_fixture(boot_timeout=True): + """mtprops with an array of preconstructed packets and an OSSerialize firmware config.""" + timeout = ('Boot TimeoutNormal Boot Ms300' + if boot_timeout else '') + config = ( + 'ProtocolZ2Min DMA Transfer Size8' + 'Power Off On ResetZ2 ConfigInter Packet Delay' + '50SPI ConfigClock Phase0' + 'Clock Polarity0Word Delay0' + 'CS Delay45Clock Period Ms62' + + timeout + + 'Calibration Sequence' + 'Providermulti-touch-calibrationAddress' + '0x10009000Max Size1800' + 'Providerorb-gap-calAddress' + '0x1000a000Max Size992' + 'Boot Sequence' + 'Address0x10003060Mask0xffffffff' + 'Value6099' + 'ActionRequestCalibration' + 'Address0x10003000Value3' + '').encode() + b'\0' + return plistlib.dumps({'TEST,2': { + 'PreconstructedBootloadPacketType': 'TESTPKT', + 'Constructed Firmware': [b'\x18\xe1\x30\x01' + bytes(range(16)), b'\x1e\x33' + bytes(14)], + 'Firmware Config': config}}) + + +def convert_packets(data, packet='TESTPKT'): + profile = plistlib.loads(data)['TEST,2'] + return z2fw.convert_packets(profile, apple_xml(profile['Firmware Config']), packet=packet) + + +class PacketEncoding(unittest.TestCase): + def test_hbpp14_blob_matches_the_driver_layout_and_roundtrips(self): + packet = z2fw.encode_blob(0x100000, b'\x01\x02\x03') + self.assertEqual(packet.hex(), '18e1300100000000001000100201000300060000') + self.assertEqual(z2fw.decode_blob(packet), (0x100000, b'\x01\x02\x03\0')) + data = bytes(range(256)) * 3 + self.assertEqual(z2fw.decode_blob(z2fw.encode_blob(0x12345678, data)), (0x12345678, data)) + for address, data in ((0, b''), (0xfffffffe, b'abcd'), (0, bytes(0x40001))): + with self.subTest(address=address, size=len(data)), self.assertRaises(ValueError): + z2fw.encode_blob(address, data) + + def test_register_read_is_two_bit_reversed_raw_transfers(self): + (c1, request), (c2, exchange) = z2fw.register_read(0x400b6020) + self.assertEqual((c1, c2), (z2fw.RAW_XFER, z2fw.RAW_XFER)) + # tx 8, rx 0, flags 1; 1c73, address word-swapped, BE16 sum of the address bytes. + self.assertEqual(request.hex(), '08000000' '00000000' '01000000' '1c73' '6020400b' '00cb') + self.assertEqual(exchange.hex(), '08000000' '08000000' '01000000' '1aa118e118e118e1') + with self.assertRaises(ValueError): + z2fw.raw_xfer(bytes(17), 0) + + def test_otp_preflight_reads_each_selected_word(self): + records = z2fw.otp_preflight_records(0x400b6000, 62, 8) + self.assertEqual(len(records), 3 + 2 * 9) + self.assertEqual(records[3], (z2fw.SEND, z2fw.register_packet(0x400b6010, 0xffffffff, 0xffffffff))) + self.assertEqual(records[4], (z2fw.SEND, z2fw.register_packet(0x400b600c, 0xffffffff, 62))) + self.assertEqual(records[13], (z2fw.SEND, z2fw.register_packet(0x400b600c, 0xffffffff, 63))) + for size in (0, 6, 260): + with self.subTest(size=size), self.assertRaises(ValueError): + z2fw.otp_preflight_records(0x400b6000, 0, size) + + def test_config_valid_mask_follows_present_fields(self): + config = {'Min DMA Transfer Size': 17, 'Power Off On Reset': True, + 'Device Tree Overrides': {'hbpp-version': struct.pack(' Date: Wed, 28 Oct 2026 15:49:31 +0800 Subject: [PATCH 12/16] Add z2fw-from-mtprops@2 and z2fw-from-mtfw@1 recipe steps z2fw-from-mtprops@2 (profile, packet) converts an mtprops whose constructed firmware is a packet array, as on the iPhone 7 Plus. z2fw-from-mtfw@1 (profile, otp_preflight) converts an MTFW, the payload of an iPad 7 multitouch IM4P, after im4p-payload@1. z2fw-from-mtprops@1 keeps its J71b behaviour; the new forms are new steps, so an older CLI asks for an update instead of misreading their parameters. STEPS now lists each parameter's type, and supported() checks it before any work. Booleans and integers do not stand in for each other. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/recipe.py | 46 +++++++++++++++++++++++++++++--------- tests/test_z2fw.py | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 11 deletions(-) diff --git a/src/orchard_cli/recipe.py b/src/orchard_cli/recipe.py index 063b47b..671812c 100644 --- a/src/orchard_cli/recipe.py +++ b/src/orchard_cli/recipe.py @@ -28,24 +28,44 @@ def _im4p_payload(step, data, expected): return im4p_payload(data) -def _z2fw(step, data, expected): - profiles = plistlib.loads(data) - profile = profiles.get(step['profile']) +def _touch_profile(data, name): + profile = plistlib.loads(data).get(name) if not isinstance(profile, dict): - raise ValueError('Touch profile not found: ' + str(step['profile'])) + raise ValueError('Touch profile not found: ' + str(name)) + return profile + + +def _z2fw(step, data, expected): + profile = _touch_profile(data, step['profile']) output, _ = z2fw.convert(profile, apple_xml(profile['Firmware Config']), packet=step['packet'], load_address=step['load_address'], hbpp_version=step['hbpp_version']) return output -# step@version -> (implementation, parameters besides 'step' and 'input') +def _z2fw_packets(step, data, expected): + profile = _touch_profile(data, step['profile']) + output, _ = z2fw.convert_packets(profile, apple_xml(profile['Firmware Config']), packet=step['packet']) + return output + + +def _z2fw_mtfw(step, data, expected): + output, _ = z2fw.convert_mtfw(apple_xml(data), step['profile'], otp_preflight=step['otp_preflight']) + return output + + +# step@version -> (implementation, {parameter besides 'step' and 'input': type}) STEPS = { - 'copy@1': (_copy, set()), - 'bluetool-hcd@1': (_bluetool_hcd, set()), - 'smartio-from-kernelcache@1': (_smartio, set()), - 'im4p-payload@1': (_im4p_payload, set()), - 'z2fw-from-mtprops@1': (_z2fw, {'profile', 'packet', 'load_address', 'hbpp_version'}), + 'copy@1': (_copy, {}), + 'bluetool-hcd@1': (_bluetool_hcd, {}), + 'smartio-from-kernelcache@1': (_smartio, {}), + 'im4p-payload@1': (_im4p_payload, {}), + # mtprops with one preconstructed packet (J71b). + 'z2fw-from-mtprops@1': (_z2fw, {'profile': str, 'packet': str, 'load_address': int, 'hbpp_version': int}), + # mtprops with an array of preconstructed packets (iPhone 7 Plus), makez2fw's layout. + 'z2fw-from-mtprops@2': (_z2fw_packets, {'profile': str, 'packet': str}), + # MTFW (IM4P payload) with HBPP load items per personality (iPad 7), makez2fw's layout. + 'z2fw-from-mtfw@1': (_z2fw_mtfw, {'profile': str, 'otp_preflight': bool}), } @@ -74,8 +94,12 @@ def supported(recipe): raise UnsupportedRecipe(f"This firmware recipe needs step {step['step']}; " 'update Orchard to a version that implements it') _, parameters = STEPS[step['step']] - if set(step) - {'step', 'input'} != parameters: + if set(step) - {'step', 'input'} != set(parameters): raise ValueError(f"Wrong parameters for {step['step']}: {sorted(set(step) - {'step', 'input'})}") + for name, kind in parameters.items(): + # bool is an int subclass; neither stands in for the other. + if not isinstance(step[name], kind) or isinstance(step[name], bool) != (kind is bool): + raise ValueError(f"Parameter {name} of {step['step']} must be {kind.__name__}") if step['input'] not in recipe['inputs']: raise ValueError('Unknown recipe input: ' + step['input']) destinations = [output['destination'] for output in recipe['outputs']] diff --git a/tests/test_z2fw.py b/tests/test_z2fw.py index cdb1610..70735fd 100644 --- a/tests/test_z2fw.py +++ b/tests/test_z2fw.py @@ -10,6 +10,7 @@ import struct import unittest +from orchard_cli import recipe from orchard_cli.apple import z2fw from orchard_cli.apple.osserialize import apple_xml @@ -197,5 +198,51 @@ def test_refuses_other_packet_types_and_data_form(self): z2fw.convert_packets(profile, apple_xml(profile['Firmware Config']), packet='TESTPKT') +class RecipeSteps(unittest.TestCase): + def document(self, steps, data, output): + return { + 'schema': 2, 'device': 'iPad7,12', + 'ipsw': {'version': '18.7.11', 'build': '22H384', 'bytes': 1, 'sha256': '0' * 64, + 'url': 'https://updates.cdn-apple.com/x.ipsw'}, + 'inputs': {'touch': {'ipsw': {'zip': 'Firmware/touch.im4p'}, 'sha256': sha(data)}}, + 'outputs': [{'destination': 'lib/firmware/apple/touch.z2fw', 'sha256': sha(output), 'steps': steps}], + } + + def test_mtfw_from_an_im4p_through_two_steps(self): + payload = mtfw_fixture() + output, _ = z2fw.convert_mtfw(apple_xml(payload), 'TEST,6', otp_preflight=True) + im4p = der(0x30, der(0x16, b'IM4P') + der(0x16, b'mtfw') + der(0x16, b'touch') + der(0x04, payload)) + steps = [{'step': 'im4p-payload@1', 'input': 'touch'}, + {'step': 'z2fw-from-mtfw@1', 'input': 'touch', 'profile': 'TEST,6', 'otp_preflight': True}] + document = recipe.supported(self.document(steps, im4p, output)) + self.assertEqual(recipe.run(document, lambda name: im4p), {'lib/firmware/apple/touch.z2fw': output}) + + def test_mtprops_packet_array_step(self): + data = mtprops_fixture() + output, _ = convert_packets(data) + steps = [{'step': 'z2fw-from-mtprops@2', 'input': 'touch', 'profile': 'TEST,2', 'packet': 'TESTPKT'}] + document = recipe.supported(self.document(steps, data, output)) + self.assertEqual(recipe.run(document, lambda name: data), {'lib/firmware/apple/touch.z2fw': output}) + + def test_parameter_types_are_checked(self): + for step in ({'step': 'z2fw-from-mtfw@1', 'input': 'touch', 'profile': 'TEST,6', 'otp_preflight': 1}, + {'step': 'z2fw-from-mtfw@1', 'input': 'touch', 'profile': 6, 'otp_preflight': True}, + {'step': 'z2fw-from-mtprops@1', 'input': 'touch', 'profile': 'K1F19,4', 'packet': 'K1HBPP14', + 'load_address': True, 'hbpp_version': 0x104}): + with self.subTest(step=step), self.assertRaisesRegex(ValueError, 'must be'): + recipe.supported(self.document([step], b'', b'')) + with self.assertRaisesRegex(ValueError, 'Wrong parameters'): + recipe.supported(self.document([{'step': 'z2fw-from-mtprops@2', 'input': 'touch', 'profile': 'x'}], + b'', b'')) + + +def der(tag, value): + length = len(value) + if length < 0x80: + return bytes([tag, length]) + value + size = (length.bit_length() + 7) // 8 + return bytes([tag, 0x80 | size]) + length.to_bytes(size, 'big') + value + + if __name__ == '__main__': unittest.main() From edc3be9c9592ec443d5af9ee9499d6ce72e0a21b Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 15:58:31 +0800 Subject: [PATCH 13/16] Launch each installation with the launcher it was installed for A successful install records the device's launcher (per ECID). orchard launch uses that record; without one, meaning an install made before 0.1.11, it uses the device's pinned legacy launcher, so old installs are not refused by the new kernel. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/launch.py | 2 +- src/orchard_cli/releases.py | 34 ++++++++++++++++++++++++--- src/orchard_cli/service.py | 4 +++- tests/test_host_workflow.py | 1 + tests/test_launch.py | 2 +- tests/test_launcher_selection.py | 40 ++++++++++++++++++++++++++++++++ tests/test_manifests.py | 2 +- 7 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 tests/test_launcher_selection.py diff --git a/src/orchard_cli/launch.py b/src/orchard_cli/launch.py index 8394fc1..d27051f 100644 --- a/src/orchard_cli/launch.py +++ b/src/orchard_cli/launch.py @@ -17,7 +17,7 @@ def launch(manifest=None, *, directory=None, target=None, allow_firmware_downloa adapter, device = choose() if ask(clock, f'Launch an installed system on {adapter.NAME}? [y/N] ', input_fn).lower() not in ('y','yes'): return {'cancelled':True} - manifest = manifest if manifest is not None else launch_manifest(adapter.PROFILE) + manifest = manifest if manifest is not None else launch_manifest(adapter.PROFILE, device.get('ecid')) record, assets = resolve(manifest, adapter.PROFILE, progress, clock=clock, purpose='boot') if not device['resumed']: adapter.boot(device, assets, record, progress) diff --git a/src/orchard_cli/releases.py b/src/orchard_cli/releases.py index b118c9e..35c5a2d 100644 --- a/src/orchard_cli/releases.py +++ b/src/orchard_cli/releases.py @@ -21,9 +21,37 @@ MAX_MANIFEST = 1024 * 1024 -def launch_manifest(profile): - """Select public launch assets from the detected device, never a distro.""" - source = hardware_device(profile)['boot'].get('launch_manifest') +def launcher_record(profile, ecid): + from .state import DeviceState + return DeviceState(profile, ecid).root / 'launcher.json' + + +def record_launcher(profile, ecid): + """Remember which launcher this device's installation needs.""" + from .state import private_directory + path = launcher_record(profile, ecid) + private_directory(path.parent) + temporary = path.with_suffix('.pending') + temporary.write_text(json.dumps({'manifest': launch_manifest(profile)}) + '\n') + temporary.chmod(0o600) + temporary.replace(path) + + +def launch_manifest(profile, ecid=None): + """Select public launch assets from the detected device, never a distro. + + An installation keeps the launcher it was installed for: the one recorded + at install time, or, for installs older than this record, the device's + pinned legacy launcher. + """ + boot = hardware_device(profile)['boot'] + source = boot.get('launch_manifest') + if ecid is not None: + path = launcher_record(profile, ecid) + if path.is_file() and not path.is_symlink(): + source = json.loads(path.read_text()).get('manifest') + elif boot.get('legacy_launch_manifest'): + source = boot['legacy_launch_manifest'] if not isinstance(source, str) or not source.startswith('https://'): raise ValueError('No launch assets published for connected device ' + profile) check_source({'url': source}, 'launch manifest') diff --git a/src/orchard_cli/service.py b/src/orchard_cli/service.py index 7c80f3c..b10547f 100644 --- a/src/orchard_cli/service.py +++ b/src/orchard_cli/service.py @@ -11,7 +11,7 @@ from .contracts import Adapter from .progress import Clock, Progress from .protocol import Client, DeviceError -from .releases import default_manifest, resolve +from .releases import default_manifest, record_launcher, resolve from .devices import choose @@ -284,6 +284,8 @@ def install(manifest=None, *, input_fn=input, progress=None): with assets['root'].open('rb') as source: result = client.call('install', {**destination, **record['assets']['root']}, source=source, size=record['assets']['root']['bytes'], timeout=1200) + # This installation is launched by the current launcher from now on. + record_launcher(adapter.PROFILE, device['ecid']) snapshot = clock.result() save_report(private / 'last-install.json', { **identity, 'installed': True, 'ready': None, 'release': record['release'], diff --git a/tests/test_host_workflow.py b/tests/test_host_workflow.py index 15eadc5..05a74e0 100644 --- a/tests/test_host_workflow.py +++ b/tests/test_host_workflow.py @@ -145,6 +145,7 @@ def check_committed_before_readiness(client, progress): self.assertIsNone(saved['timing']['install_seconds']) return wait_ready(client, progress) with patch('orchard_cli.service.choose', return_value=(adapter, {'mode':'dfu', 'location':'opaque', 'ecid':1, 'resumed':False})), \ + patch('orchard_cli.service.record_launcher') as recorded, \ patch('orchard_cli.service.resolve', side_effect=acquire_assets), \ patch('orchard_cli.service.Client', return_value=device), \ patch('orchard_cli.state.cache_root', return_value=root), \ diff --git a/tests/test_launch.py b/tests/test_launch.py index aff66a2..1288ce3 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -61,7 +61,7 @@ def call(operation,request=None,**kwargs): self.assertEqual(sum(c.args[0]=='launch' for c in client.call.call_args_list),1) self.assertEqual(resolve.call_args.kwargs['purpose'],'boot') if manifest is None: - selected_manifest.assert_called_once_with('j71b') + self.assertEqual(selected_manifest.call_args.args[0], 'j71b') self.assertEqual(resolve.call_args.args[0], 'https://example.invalid/j71b.boot.toml') else: selected_manifest.assert_not_called() diff --git a/tests/test_launcher_selection.py b/tests/test_launcher_selection.py new file mode 100644 index 0000000..06e0d48 --- /dev/null +++ b/tests/test_launcher_selection.py @@ -0,0 +1,40 @@ +"""Installations keep the launcher they were installed for.""" +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from orchard_cli import releases + +DESCRIPTOR = {'boot': { + 'launch_manifest': 'https://example.invalid/latest/download/board-apple-launcher.boot.toml', + 'legacy_launch_manifest': 'https://example.invalid/download/v0.1.10/board-launcher.boot.toml', +}} + + +class LauncherSelection(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + for target in ('orchard_cli.state.cache_root',): + p = patch(target, return_value=Path(temporary.name)) + p.start() + self.addCleanup(p.stop) + p = patch.object(releases, 'hardware_device', return_value=DESCRIPTOR) + p.start() + self.addCleanup(p.stop) + + def test_install_without_record_keeps_the_legacy_launcher(self): + self.assertEqual(releases.launch_manifest('board', 1), DESCRIPTOR['boot']['legacy_launch_manifest']) + + def test_recorded_install_uses_the_launcher_it_was_installed_for(self): + releases.record_launcher('board', 1) + self.assertEqual(releases.launch_manifest('board', 1), DESCRIPTOR['boot']['launch_manifest']) + self.assertEqual(releases.launch_manifest('board', 2), DESCRIPTOR['boot']['legacy_launch_manifest']) + + def test_without_a_device_the_current_launcher_is_used(self): + self.assertEqual(releases.launch_manifest('board'), DESCRIPTOR['boot']['launch_manifest']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_manifests.py b/tests/test_manifests.py index 8dfbc0d..2cc41e4 100644 --- a/tests/test_manifests.py +++ b/tests/test_manifests.py @@ -217,7 +217,7 @@ def test_hardware_descriptor_remains_independent(self): def test_hardware_launch_manifest_is_optional_and_requires_https(self): document = load(device_path('j71b')) self.assertEqual(document['boot']['launch_manifest'], - 'https://github.com/OrchardLinux/hardware/releases/latest/download/j71b-launcher.boot.toml') + 'https://github.com/OrchardLinux/hardware/releases/latest/download/j71b-apple-launcher.boot.toml') for value in ('http://example.invalid/boot.toml', '/local/boot.toml', '', True): with self.subTest(value=value): invalid = deepcopy(document) From 47e9c9c86048fbb51214eabdba2d629b53d89150 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 16:03:18 +0800 Subject: [PATCH 14/16] Load Apple's touch calibration defaults and the iPad 7 providers Each MTFW Property item now loads its default payload before the calibration record, as makez2fw --dynamic-calibration does, so a unit whose ADT lacks the property keeps Apple's default. Map the Firefly P100 (7) and external multi-touch (8) providers apple_z2 now knows. Without the six calibration records, the J171/J172 K1F19,6 output equals makez2fw --dynamic-calibration's command for command. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/apple/z2fw.py | 10 +++++++--- tests/test_z2fw.py | 21 +++++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/orchard_cli/apple/z2fw.py b/src/orchard_cli/apple/z2fw.py index 479ef9a..6888c48 100644 --- a/src/orchard_cli/apple/z2fw.py +++ b/src/orchard_cli/apple/z2fw.py @@ -35,7 +35,8 @@ # drivers/input/touchscreen/apple_z2.c), keyed by the ADT property name. PROVIDERS = {'multi-touch-calibration': 0, 'orb-gap-cal': 1, 'orb-force-cal': 2, 'shape-dynamic-accel-cal': 3, 'multitouch-to-display-offset': 4, - 'firefly-calibration': 5, 'pressure-effect-correction': 6} + 'firefly-calibration': 5, 'pressure-effect-correction': 6, + 'firefly-p100-calibration': 7, 'ext-multi-touch-calibration': 8} def uint(value, name, maximum=0xffffffff): @@ -352,8 +353,9 @@ def convert_mtfw(mtfw, profile, *, otp_preflight): """MTFW personality listing HBPP load items (ReadModifyWrite, Binary, Property, Config). Same commands, order and words as makez2fw, except that each Property - (per-unit calibration, which makez2fw loads from SysCfg) becomes a - calibration record. + (per-unit calibration, which makez2fw loads from SysCfg) loads Apple's + default payload, then a calibration record. apple_z2 skips a record + whose DT property is missing, so such a unit keeps the default. """ items = mtfw.get(profile) if isinstance(mtfw, dict) else None if not isinstance(items, list) or not all(isinstance(item, dict) for item in items): @@ -383,6 +385,8 @@ def convert_mtfw(mtfw, profile, *, otp_preflight): records.append((SEND, encode_blob(address, item.get('Payload')))) loads.append({'address': address, 'bytes': len(item['Payload'])}) elif kind == 'Property': + if item.get('Payload'): + records.append((SEND, encode_blob(address, item['Payload']))) records.append(calibration_record(item.get('Property'), address, item.get('MaxSize'), providers)) else: raise ValueError('Unsupported HBPP item type: ' + str(kind)) diff --git a/tests/test_z2fw.py b/tests/test_z2fw.py index 70735fd..bf9f8b8 100644 --- a/tests/test_z2fw.py +++ b/tests/test_z2fw.py @@ -136,29 +136,38 @@ def test_matches_makez2fw_without_calibration(self): self.assertEqual(sha(output), '23f44fec2e6e8c49f77567cc4dc6b7f1dee1666dc2fb0c35df6e61f371d62d87') self.assertTrue(info['otp_preflight']) - def test_property_becomes_a_calibration_record_in_place(self): + def test_property_loads_the_default_then_a_calibration_record_in_place(self): root = apple_xml(mtfw_fixture()) output, info = z2fw.convert_mtfw(root, 'TEST,6', otp_preflight=True) - self.assertEqual(sha(output), 'e42663f22b32718725a5c219494ed7d7bfc573384115677e35e0362d28d9f66c') + self.assertEqual(sha(output), '4edff904c3206be551f7d762069cb19ae388fb9f757fdfa386153d890fb4de7e') records = z2fw.decode_container(output) commands = [code for code, _ in records] self.assertEqual(commands[:2], [z2fw.CONFIG, z2fw.INIT]) self.assertEqual(commands[-5:], [z2fw.SEND, z2fw.SEND, z2fw.CALIBRATION, z2fw.SEND, z2fw.WAIT]) + # Apple's default stays loaded on a unit whose ADT lacks the property. + self.assertEqual(z2fw.decode_blob(records[-4][1]), (0x100400, bytes(8))) self.assertEqual(records[-3][1], struct.pack(' Date: Wed, 28 Oct 2026 16:10:34 +0800 Subject: [PATCH 15/16] Feed later recipe steps the previous step's result Only an output's first step names an input. The MTFW conversion after im4p-payload is the first chain; it no longer repeats an input that run() ignored. Co-Authored-By: Claude Opus 5.5 --- src/orchard_cli/recipe.py | 12 ++++++++---- tests/test_recipe.py | 12 ++++++++++++ tests/test_z2fw.py | 2 +- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/orchard_cli/recipe.py b/src/orchard_cli/recipe.py index 671812c..878afc8 100644 --- a/src/orchard_cli/recipe.py +++ b/src/orchard_cli/recipe.py @@ -89,6 +89,10 @@ def supported(recipe): if recipe.get('schema') != 2: raise UnsupportedRecipe('This firmware recipe format needs a newer Orchard') for output in recipe['outputs']: + # The first step reads a recipe input; each later step, the previous result. + if not output['steps'] or 'input' not in output['steps'][0] or \ + any('input' in step for step in output['steps'][1:]): + raise ValueError('Only the first step of an output names an input: ' + output['destination']) for step in output['steps']: if step['step'] not in STEPS: raise UnsupportedRecipe(f"This firmware recipe needs step {step['step']}; " @@ -100,7 +104,7 @@ def supported(recipe): # bool is an int subclass; neither stands in for the other. if not isinstance(step[name], kind) or isinstance(step[name], bool) != (kind is bool): raise ValueError(f"Parameter {name} of {step['step']} must be {kind.__name__}") - if step['input'] not in recipe['inputs']: + if 'input' in step and step['input'] not in recipe['inputs']: raise ValueError('Unknown recipe input: ' + step['input']) destinations = [output['destination'] for output in recipe['outputs']] if len(set(destinations)) != len(destinations): @@ -110,7 +114,7 @@ def supported(recipe): def needed_inputs(recipe, outputs=None): outputs = recipe['outputs'] if outputs is None else outputs - return sorted({step['input'] for output in outputs for step in output['steps']}) + return sorted({output['steps'][0]['input'] for output in outputs}) def run(recipe, read_input, outputs=None): @@ -131,10 +135,10 @@ def source(name): result = {} for output in recipe['outputs'] if outputs is None else outputs: - data = None + data = source(output['steps'][0]['input']) for step in output['steps']: implementation, _ = STEPS[step['step']] - data = implementation(step, source(step['input']) if data is None else data, output['sha256']) + data = implementation(step, data, output['sha256']) if hashlib.sha256(data).hexdigest() != output['sha256']: raise ValueError('Firmware output differs from the recipe: ' + output['destination']) result[output['destination']] = data diff --git a/tests/test_recipe.py b/tests/test_recipe.py index 608130b..8456f9f 100644 --- a/tests/test_recipe.py +++ b/tests/test_recipe.py @@ -52,6 +52,18 @@ def test_needed_inputs_are_only_those_of_the_requested_outputs(self): self.assertEqual(recipe.needed_inputs(document), ['blob']) self.assertEqual(recipe.needed_inputs(document, []), []) + def test_later_steps_take_the_previous_result(self): + container = der(0x30, der(0x16, b'IM4P') + der(0x16, b'mtfw') + der(0x16, b'touch') + + der(0x04, b'firmware')) + document = sample([{'step': 'im4p-payload@1', 'input': 'blob'}, {'step': 'copy@1'}], container) + document['outputs'][0]['sha256'] = sha(b'firmware') + recipe.check(document) + self.assertEqual(recipe.run(document, lambda name: container), + {'lib/firmware/apple/blob': b'firmware'}) + document['outputs'][0]['steps'][1]['input'] = 'blob' + with self.assertRaisesRegex(ValueError, 'Only the first step'): + recipe.supported(document) + def test_im4p_payload(self): payload = b'\x7fELF' + bytes(300) container = der(0x30, der(0x16, b'IM4P') + der(0x16, b'mtfw') + der(0x16, b'touch') diff --git a/tests/test_z2fw.py b/tests/test_z2fw.py index bf9f8b8..eb6be8f 100644 --- a/tests/test_z2fw.py +++ b/tests/test_z2fw.py @@ -222,7 +222,7 @@ def test_mtfw_from_an_im4p_through_two_steps(self): output, _ = z2fw.convert_mtfw(apple_xml(payload), 'TEST,6', otp_preflight=True) im4p = der(0x30, der(0x16, b'IM4P') + der(0x16, b'mtfw') + der(0x16, b'touch') + der(0x04, payload)) steps = [{'step': 'im4p-payload@1', 'input': 'touch'}, - {'step': 'z2fw-from-mtfw@1', 'input': 'touch', 'profile': 'TEST,6', 'otp_preflight': True}] + {'step': 'z2fw-from-mtfw@1', 'profile': 'TEST,6', 'otp_preflight': True}] document = recipe.supported(self.document(steps, im4p, output)) self.assertEqual(recipe.run(document, lambda name: im4p), {'lib/firmware/apple/touch.z2fw': output}) From 067395a24141f3e81abe11859ad328b134b76681 Mon Sep 17 00:00:00 2001 From: Henrik Klee Date: Wed, 28 Oct 2026 17:44:02 +0800 Subject: [PATCH 16/16] Package and typecheck the generic adapter, not the removed j71b one The wheel still listed orchard_cli.j71b, so installing the CLI failed. The Adapter protocol now declares the descriptor data the host reads. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 6 +++--- src/orchard_cli/contracts.py | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7286419..2506934 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ orch = "orchard_cli.command:main" [tool.setuptools] include-package-data = false -packages = ["orchard_cli", "orchard_cli.j71b", "orchard_cli.apple", "orchard_cli.transports", "orchard_cli_schemas"] +packages = ["orchard_cli", "orchard_cli.apple", "orchard_cli.transports", "orchard_cli_schemas"] package-dir = {"" = "src", orchard_cli_schemas = "schemas"} [tool.setuptools.package-data] @@ -36,12 +36,12 @@ dev = ["mypy==1.18.2"] [tool.mypy] python_version = "3.9" mypy_path = "src" -files = ["src/orchard_cli/contracts.py", "src/orchard_cli/devices.py", "src/orchard_cli/j71b/adapter.py", "src/orchard_cli/j71b/inputs.py", "src/orchard_cli/j71b/mixin.py", "src/orchard_cli/service.py", "src/orchard_cli/command.py", "tests/typecheck_adapter.py"] +files = ["src/orchard_cli/contracts.py", "src/orchard_cli/devices.py", "src/orchard_cli/apple/device.py", "src/orchard_cli/service.py", "src/orchard_cli/command.py", "tests/typecheck_adapter.py"] check_untyped_defs = true follow_imports = "silent" ignore_missing_imports = true warn_unused_ignores = true [[tool.mypy.overrides]] -module = ["orchard_cli.contracts", "orchard_cli.devices", "orchard_cli.j71b.adapter", "orchard_cli.j71b.inputs", "orchard_cli.j71b.mixin"] +module = ["orchard_cli.contracts", "orchard_cli.devices", "orchard_cli.apple.device"] disallow_untyped_defs = true diff --git a/src/orchard_cli/contracts.py b/src/orchard_cli/contracts.py index 767cc90..32ab5eb 100644 --- a/src/orchard_cli/contracts.py +++ b/src/orchard_cli/contracts.py @@ -79,6 +79,15 @@ class Adapter(Protocol): PROFILE: str NAME: str REBOOT_MESSAGE: str + # USB product of the device's recovery system; None until it has one. + PRODUCT: Optional[str] + # Support data from the hardware descriptor ([support], [features]). + INSTALL: bool + STATUS: str + CAVEATS: List[str] + FEATURES: Dict[str, str] + + def matches_dfu(self, device: Dict[str, Any]) -> bool: ... def list_devices(self) -> List[Dict[str, Any]]: ...