feat(open_aot_patch_tools): enhance runtime mode handling and iOS App Store safety checks
ci / 📄 License Check (push) Has been cancelled
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled
ci / 🔎 Verify ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
Shorebird CI / artifact_proxy (push) Has been cancelled
Shorebird CI / dex (push) Has been cancelled
Shorebird CI / discord_gcp_alerts (push) Has been cancelled
Shorebird CI / flutter_version_resolver (push) Has been cancelled
Shorebird CI / jwt (push) Has been cancelled
Shorebird CI / scoped_deps (push) Has been cancelled
Shorebird CI / shorebird_build_trace (push) Has been cancelled
Shorebird CI / shorebird_ci (push) Has been cancelled
Shorebird CI / shorebird_cli (push) Has been cancelled
Shorebird CI / shorebird_code_push_client (push) Has been cancelled
Shorebird CI / shorebird_code_push_protocol (push) Has been cancelled
Shorebird CI / shorebird_redis_client (push) Has been cancelled
Shorebird CI / stripe_api (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled
ci / 📄 License Check (push) Has been cancelled
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled
ci / 🔎 Verify ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
Shorebird CI / artifact_proxy (push) Has been cancelled
Shorebird CI / dex (push) Has been cancelled
Shorebird CI / discord_gcp_alerts (push) Has been cancelled
Shorebird CI / flutter_version_resolver (push) Has been cancelled
Shorebird CI / jwt (push) Has been cancelled
Shorebird CI / scoped_deps (push) Has been cancelled
Shorebird CI / shorebird_build_trace (push) Has been cancelled
Shorebird CI / shorebird_ci (push) Has been cancelled
Shorebird CI / shorebird_cli (push) Has been cancelled
Shorebird CI / shorebird_code_push_client (push) Has been cancelled
Shorebird CI / shorebird_code_push_protocol (push) Has been cancelled
Shorebird CI / shorebird_redis_client (push) Has been cancelled
Shorebird CI / stripe_api (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled
Signed-off-by: Tony <tonylu@tony-cloud.com>
This commit is contained in:
@@ -32,6 +32,8 @@ void main(List<String> args) {
|
|||||||
void _link(Map<String, String> options) {
|
void _link(Map<String, String> options) {
|
||||||
final base = File(_required(options, 'base')).readAsBytesSync();
|
final base = File(_required(options, 'base')).readAsBytesSync();
|
||||||
final patch = File(_required(options, 'patch')).readAsBytesSync();
|
final patch = File(_required(options, 'patch')).readAsBytesSync();
|
||||||
|
final targetOs = _required(options, 'target-os');
|
||||||
|
final runtimeMode = _runtimeModeForLink(options, targetOs);
|
||||||
final metadata = PatchMetadata(
|
final metadata = PatchMetadata(
|
||||||
appId: _required(options, 'app-id'),
|
appId: _required(options, 'app-id'),
|
||||||
appBuildId: _required(options, 'app-build-id'),
|
appBuildId: _required(options, 'app-build-id'),
|
||||||
@@ -42,8 +44,9 @@ void _link(Map<String, String> options) {
|
|||||||
sdkHash: _required(options, 'sdk-hash'),
|
sdkHash: _required(options, 'sdk-hash'),
|
||||||
baseSnapshotHash: sha256Hex(base),
|
baseSnapshotHash: sha256Hex(base),
|
||||||
patchSnapshotHash: sha256Hex(patch),
|
patchSnapshotHash: sha256Hex(patch),
|
||||||
targetOs: _required(options, 'target-os'),
|
targetOs: targetOs,
|
||||||
targetArch: _required(options, 'target-arch'),
|
targetArch: _required(options, 'target-arch'),
|
||||||
|
runtimeMode: runtimeMode,
|
||||||
obfuscationMapHash: options['obfuscation-map-hash'],
|
obfuscationMapHash: options['obfuscation-map-hash'],
|
||||||
offlineExpiresAt: _optionalIso8601Utc(options['offline-expires-at']),
|
offlineExpiresAt: _optionalIso8601Utc(options['offline-expires-at']),
|
||||||
);
|
);
|
||||||
@@ -58,6 +61,24 @@ void _link(Map<String, String> options) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _runtimeModeForLink(Map<String, String> options, String targetOs) {
|
||||||
|
final runtimeMode = options['runtime-mode'];
|
||||||
|
if (targetOs == 'ios' && (runtimeMode == null || runtimeMode.isEmpty)) {
|
||||||
|
throw ArgumentError(
|
||||||
|
'Missing --runtime-mode for iOS. Use '
|
||||||
|
'--runtime-mode=$runtimeModeDartBytecodeInterpreter for App Store-safe '
|
||||||
|
'interpreter patches, or explicitly pass '
|
||||||
|
'--runtime-mode=$runtimeModeNativeAot for development-only native AOT '
|
||||||
|
'artifacts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final resolved = runtimeMode ?? runtimeModeNativeAot;
|
||||||
|
if (!knownRuntimeModes.contains(resolved)) {
|
||||||
|
throw ArgumentError('Unsupported --runtime-mode=$resolved');
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
void _encrypt(Map<String, String> options) {
|
void _encrypt(Map<String, String> options) {
|
||||||
final artifact = PatchArtifact.fromJson(
|
final artifact = PatchArtifact.fromJson(
|
||||||
readJsonFile(File(_required(options, 'input'))),
|
readJsonFile(File(_required(options, 'input'))),
|
||||||
@@ -86,6 +107,7 @@ void _verify(Map<String, String> options) {
|
|||||||
baseLicenseType: options['base-license-type'],
|
baseLicenseType: options['base-license-type'],
|
||||||
flavorId: _required(options, 'flavor-id'),
|
flavorId: _required(options, 'flavor-id'),
|
||||||
licenseType: _required(options, 'license-type'),
|
licenseType: _required(options, 'license-type'),
|
||||||
|
requireIosAppStoreSafe: _boolOption(options, 'require-ios-app-store-safe'),
|
||||||
now: _optionalDateTime(options['now']),
|
now: _optionalDateTime(options['now']),
|
||||||
allowExpired: _boolOption(options, 'allow-expired'),
|
allowExpired: _boolOption(options, 'allow-expired'),
|
||||||
);
|
);
|
||||||
@@ -353,8 +375,8 @@ void _usage() {
|
|||||||
Usage:
|
Usage:
|
||||||
open_aot_patch_tools dump-blobs --input=<file> [--key-hex=<64 hex chars>] [--base=<file>] [--output=<vmcode>] [--now=<iso8601>] [--allow-expired=true]
|
open_aot_patch_tools dump-blobs --input=<file> [--key-hex=<64 hex chars>] [--base=<file>] [--output=<vmcode>] [--now=<iso8601>] [--allow-expired=true]
|
||||||
open_aot_patch_tools compile-patch --gen-snapshot=<path> --kernel=<dill> --output=<file> [--snapshot-kind=app-aot-elf|app-aot-macho-dylib|app-aot-assembly] [--macho-object=<file>] [--obfuscate=true] [--load-obfuscation-map=<file>] [--save-obfuscation-map=<file>]
|
open_aot_patch_tools compile-patch --gen-snapshot=<path> --kernel=<dill> --output=<file> [--snapshot-kind=app-aot-elf|app-aot-macho-dylib|app-aot-assembly] [--macho-object=<file>] [--obfuscate=true] [--load-obfuscation-map=<file>] [--save-obfuscation-map=<file>]
|
||||||
open_aot_patch_tools link --base=<file> --patch=<file> --output=<file> --app-id=<id> --app-build-id=<id> --flavor-id=<id> --license-type=<type> --sdk-hash=<hash> --target-os=<os> --target-arch=<arch> [--base-flavor-id=<id>] [--base-license-type=<type>] [--obfuscation-map-hash=<sha256>] [--offline-expires-at=<iso8601>] [--full-snapshot=true]
|
open_aot_patch_tools link --base=<file> --patch=<file> --output=<file> --app-id=<id> --app-build-id=<id> --flavor-id=<id> --license-type=<type> --sdk-hash=<hash> --target-os=<os> --target-arch=<arch> [--runtime-mode=native-aot|dart-bytecode-interpreter] [--base-flavor-id=<id>] [--base-license-type=<type>] [--obfuscation-map-hash=<sha256>] [--offline-expires-at=<iso8601>] [--full-snapshot=true]
|
||||||
open_aot_patch_tools encrypt --input=<file> --output=<file> --key-id=<id> --key-hex=<64 hex chars> --nonce-hex=<24 hex chars>
|
open_aot_patch_tools encrypt --input=<file> --output=<file> --key-id=<id> --key-hex=<64 hex chars> --nonce-hex=<24 hex chars>
|
||||||
open_aot_patch_tools verify --input=<file> --key-hex=<64 hex chars> --flavor-id=<id> --license-type=<type> [--base=<file>] [--artifact-sha256=<sha256>] [--base-flavor-id=<id>] [--base-license-type=<type>] [--now=<iso8601>] [--allow-expired=true]
|
open_aot_patch_tools verify --input=<file> --key-hex=<64 hex chars> --flavor-id=<id> --license-type=<type> [--base=<file>] [--artifact-sha256=<sha256>] [--base-flavor-id=<id>] [--base-license-type=<type>] [--require-ios-app-store-safe=true] [--now=<iso8601>] [--allow-expired=true]
|
||||||
''');
|
''');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
# Open AOT Patch Design
|
# Open AOT Patch Design
|
||||||
|
|
||||||
This package produces compact, flavor-aware AOT patch artifacts without relying
|
This package produces compact, flavor-aware patch artifacts without relying on
|
||||||
on `DART_DYNAMIC_MODULES` or the Dart bytecode interpreter.
|
`DART_DYNAMIC_MODULES`. Native AOT artifacts are still useful for host,
|
||||||
|
Android, macOS, and development-device iOS validation, but iOS App Store patch
|
||||||
|
delivery must use a no-DDM interpreter runtime mode instead of downloaded native
|
||||||
|
snapshot text.
|
||||||
|
|
||||||
The public-source boundary is documented in
|
The public-source boundary is documented in
|
||||||
`../../../OPEN_SOURCE_REPLACEMENTS.md`. The Shorebird runtime updater itself is
|
`../../../OPEN_SOURCE_REPLACEMENTS.md`. The Shorebird runtime updater itself is
|
||||||
@@ -11,16 +14,21 @@ AOT patch generation flow.
|
|||||||
|
|
||||||
## Runtime Model
|
## Runtime Model
|
||||||
|
|
||||||
Patches are AOT snapshot artifacts. The engine selects patched isolate snapshot
|
The artifact metadata names the runtime mode required by the patch payload:
|
||||||
data and instructions before isolate startup, matching Flutter's existing
|
|
||||||
snapshot mapping flow. The VM data and VM instructions remain from the base
|
- `native-aot`: patched isolate snapshot data/instructions are selected before
|
||||||
runtime.
|
isolate startup, matching Flutter's existing snapshot mapping flow. VM data
|
||||||
|
and VM instructions remain from the base runtime.
|
||||||
|
- `dart-bytecode-interpreter`: iOS App Store-safe payloads are data consumed by
|
||||||
|
already-reviewed VM interpreter code. This is not `DART_DYNAMIC_MODULES`, and
|
||||||
|
the VM must reject dynamic-module payloads for this project.
|
||||||
|
|
||||||
This keeps the design compatible with iOS constraints:
|
This keeps the design compatible with iOS constraints:
|
||||||
|
|
||||||
- no JIT dependency
|
- no JIT dependency
|
||||||
- no writable executable memory requirement
|
- no writable executable memory requirement for App Store payloads
|
||||||
- no KBC interpreter dependency
|
- no downloaded native snapshot text in App Store payloads
|
||||||
|
- no `DART_DYNAMIC_MODULES`
|
||||||
- patch metadata must bind the artifact to platform, architecture, SDK hash,
|
- patch metadata must bind the artifact to platform, architecture, SDK hash,
|
||||||
app build id, flavor/license id, base snapshot hash, patch snapshot hash, and
|
app build id, flavor/license id, base snapshot hash, patch snapshot hash, and
|
||||||
obfuscation map hash when obfuscation is used
|
obfuscation map hash when obfuscation is used
|
||||||
@@ -59,17 +67,26 @@ optional `remove_patch` object, which contains the same patch number,
|
|||||||
|
|
||||||
## Compact Payloads
|
## Compact Payloads
|
||||||
|
|
||||||
By default, `link` chooses the smallest v1 payload representation:
|
For native-AOT artifacts, `link` chooses the smallest v1 payload
|
||||||
|
representation by default:
|
||||||
|
|
||||||
- `empty` when base and patch snapshots are identical
|
- `empty` when base and patch snapshots are identical
|
||||||
- `binary-diff-v1` when changed byte ranges are smaller than the full patch
|
- `binary-diff-v1` when changed byte ranges are smaller than the full patch
|
||||||
- `full-snapshot` when a diff would be larger
|
- `full-snapshot` when a diff would be larger
|
||||||
|
|
||||||
For runtime handoff tests and simple embedders, pass
|
For native-AOT runtime handoff tests and simple embedders, pass
|
||||||
`link --full-snapshot=true`. This forces the encrypted payload to decrypt to a
|
`link --full-snapshot=true`. This forces the encrypted payload to decrypt to a
|
||||||
directly loadable `.vmcode` file. That file can be supplied as the first
|
directly loadable `.vmcode` file. That file can be supplied as the first
|
||||||
application library path and mapped by the engine patch cache before isolate
|
application library path and mapped by the engine patch cache before isolate
|
||||||
startup.
|
startup on platforms where native patch execution is allowed. Do not use this
|
||||||
|
native-AOT handoff for iOS App Store delivery.
|
||||||
|
|
||||||
|
For `dart-bytecode-interpreter` artifacts, the linker always emits
|
||||||
|
`full-snapshot` payloads. The current open runtime has no compact-diff
|
||||||
|
reconstruction bridge inside the VM, so interpreter patches must decrypt to
|
||||||
|
bytecode data. Product iOS now has an initial AOT-safe replacement mapper for
|
||||||
|
bytecode that describes already-loaded libraries/classes/functions; broader
|
||||||
|
payload shapes still need compiler and runtime work.
|
||||||
|
|
||||||
The binary diff format is deterministic and reconstructs the full patched
|
The binary diff format is deterministic and reconstructs the full patched
|
||||||
snapshot from the base snapshot before runtime loading. Future SDK-integrated
|
snapshot from the base snapshot before runtime loading. Future SDK-integrated
|
||||||
@@ -77,9 +94,9 @@ linkers can replace this with changed isolate code/data sections while keeping
|
|||||||
the same metadata and encryption envelope.
|
the same metadata and encryption envelope.
|
||||||
|
|
||||||
`dump-blobs --key-hex=<key> --base=<base.vmcode> --output=<patch.vmcode>` is
|
`dump-blobs --key-hex=<key> --base=<base.vmcode> --output=<patch.vmcode>` is
|
||||||
the open updater handoff: it decrypts the delivery artifact, reconstructs the
|
the native-AOT updater handoff: it decrypts the delivery artifact, reconstructs
|
||||||
loadable VM-code payload, verifies the reconstructed hash against metadata, and
|
the loadable VM-code payload, verifies the reconstructed hash against metadata,
|
||||||
writes the file that the engine patch cache can map before isolate startup.
|
and writes the file that the engine patch cache can map before isolate startup.
|
||||||
|
|
||||||
The host proof app is `testapps/license_flavor_patch_test`. Run:
|
The host proof app is `testapps/license_flavor_patch_test`. Run:
|
||||||
|
|
||||||
@@ -92,11 +109,26 @@ base obfuscation map for the patch build, encrypts the patch artifact, verifies
|
|||||||
wrong-key and wrong-flavor failures, reconstructs the patched `.vmcode`, and
|
wrong-key and wrong-flavor failures, reconstructs the patched `.vmcode`, and
|
||||||
runs it to confirm `license:pro` and `pro-feature:enabled`.
|
runs it to confirm `license:pro` and `pro-feature:enabled`.
|
||||||
|
|
||||||
## iOS JIT-Disabled Handoff
|
## iOS App Store Handoff
|
||||||
|
|
||||||
iOS release/profile patch artifacts should be generated as Mach-O AOT snapshot
|
`DART_DYNAMIC_MODULES` must not be enabled for this project. It adds the dynamic
|
||||||
objects, not bytecode and not dynamic modules. Build the patch snapshot on
|
module interpreter surface and can package code that should not ship in the
|
||||||
macOS with the iOS `gen_snapshot` that matches the app's Flutter/Dart SDK:
|
target app.
|
||||||
|
|
||||||
|
The native Mach-O `.vmcode` path is development-only on iOS. It proves the base
|
||||||
|
snapshot metadata, updater state machine, and flavor/license transition, but it
|
||||||
|
still maps downloaded native snapshot text as executable memory. A physical
|
||||||
|
device accepts that only when the payload is signed appropriately, and that is
|
||||||
|
not the App Store patch route.
|
||||||
|
|
||||||
|
The App Store route follows Shorebird's public architecture shape: the updater
|
||||||
|
selects a patch before Dart starts, then the Dart SDK installs a payload that is
|
||||||
|
interpreted by VM code already present in the app. The payload runtime mode is
|
||||||
|
`dart-bytecode-interpreter`; it is explicitly separate from
|
||||||
|
`DART_DYNAMIC_MODULES`.
|
||||||
|
|
||||||
|
For native development-device checks, build the patch snapshot on macOS with the
|
||||||
|
iOS `gen_snapshot` that matches the app's Flutter/Dart SDK:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
dart run open_aot_patch_tools compile-patch \
|
dart run open_aot_patch_tools compile-patch \
|
||||||
@@ -121,34 +153,97 @@ dart run open_aot_patch_tools link \
|
|||||||
--sdk-hash=<flutter-engine-or-dart-sdk-hash> \
|
--sdk-hash=<flutter-engine-or-dart-sdk-hash> \
|
||||||
--target-os=ios \
|
--target-os=ios \
|
||||||
--target-arch=arm64 \
|
--target-arch=arm64 \
|
||||||
|
--runtime-mode=native-aot \
|
||||||
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
|
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
|
||||||
--offline-expires-at=2030-01-01T00:00:00Z \
|
--offline-expires-at=2030-01-01T00:00:00Z \
|
||||||
--full-snapshot=true
|
--full-snapshot=true
|
||||||
```
|
```
|
||||||
|
|
||||||
The encrypted artifact is delivered the same way as the Windows host proof:
|
For App Store-safe iOS artifacts, the linker must use:
|
||||||
`encrypt` wraps it with AES-256-GCM, and the app-owned updater/key callback must
|
|
||||||
validate metadata before decrypting. After decryption, use
|
```sh
|
||||||
`dump-blobs --key-hex=<key> --base=build/base.vmcode --output=patch.vmcode` to
|
dart run open_aot_patch_tools link \
|
||||||
write the loadable patch file into the updater's next-boot patch location.
|
--base=build/base.interp \
|
||||||
|
--patch=build/patch.interp \
|
||||||
|
--output=build/patch.json \
|
||||||
|
--app-id=com.example.licenseFlavorPatchTest \
|
||||||
|
--app-build-id=ios-build-123 \
|
||||||
|
--base-flavor-id=free \
|
||||||
|
--base-license-type=free \
|
||||||
|
--flavor-id=pro \
|
||||||
|
--license-type=pro \
|
||||||
|
--sdk-hash=<flutter-engine-or-dart-sdk-hash> \
|
||||||
|
--target-os=ios \
|
||||||
|
--target-arch=arm64 \
|
||||||
|
--runtime-mode=dart-bytecode-interpreter \
|
||||||
|
--obfuscation-map-hash=<sha256-of-base-obfuscation-map> \
|
||||||
|
--offline-expires-at=2030-01-01T00:00:00Z
|
||||||
|
```
|
||||||
|
|
||||||
|
`--full-snapshot=true` is not needed for `dart-bytecode-interpreter`; it is
|
||||||
|
forced by the linker and enforced by `Dart_InstallAotPatch`.
|
||||||
|
|
||||||
|
The encrypted artifact is delivered the same way as the host proof: `encrypt`
|
||||||
|
wraps it with AES-256-GCM, and the app-owned updater/key callback must validate
|
||||||
|
metadata before decrypting. `Dart_InstallAotPatch` rejects iOS native-AOT
|
||||||
|
artifacts, rejects any dynamic-modules runtime mode, and rejects compact
|
||||||
|
interpreter payloads before payload installation.
|
||||||
|
|
||||||
|
The open Flutter engine bridge can now pass the encrypted artifact selected by
|
||||||
|
the updater into `Dart_InstallAotPatch`, then hand the decrypted bytecode to
|
||||||
|
`Dart_ReloadBytecodePatch`. This bridge is intentionally no-DDM and never adds
|
||||||
|
interpreter patches to native `application_library_paths`. In product iOS AOT,
|
||||||
|
`Dart_ReloadBytecodePatch` uses the no-DDM bytecode replacement mapper instead
|
||||||
|
of stock VM reload: it reads the bytecode component as data, resolves existing
|
||||||
|
libraries/classes/functions from the signed base snapshot, and switches matched
|
||||||
|
functions to the VM's signed `InterpretCall` stub with attached bytecode. This
|
||||||
|
does not enable `DART_DYNAMIC_MODULES` and does not map downloaded executable
|
||||||
|
memory.
|
||||||
|
|
||||||
|
For local encrypted interpreter tests, `shorebird.yaml` may provide a
|
||||||
|
development AES key and expected metadata:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
aot_patch_key_id: test-key
|
||||||
|
aot_patch_key_hex: 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f
|
||||||
|
aot_patch_base_flavor_id: free
|
||||||
|
aot_patch_base_license_type: free
|
||||||
|
aot_patch_flavor_id: pro
|
||||||
|
aot_patch_license_type: pro
|
||||||
|
aot_patch_sdk_hash: <flutter-engine-or-dart-sdk-hash>
|
||||||
|
aot_patch_base_snapshot_hash: <sha256-of-base-bytecode-snapshot>
|
||||||
|
```
|
||||||
|
|
||||||
|
Embedding a symmetric key in `shorebird.yaml` is a development bridge, not the
|
||||||
|
final production key strategy. The production replacement should supply the AES
|
||||||
|
key through an app-owned callback or platform key source while keeping the same
|
||||||
|
VM validation and bytecode reload path.
|
||||||
|
|
||||||
The Flutter engine patch cache is prepared for this handoff:
|
The Flutter engine patch cache is prepared for this handoff:
|
||||||
|
|
||||||
- `shorebird_enable_aot_patching` defaults on for iOS, while the legacy
|
- `dart_enable_aot_patching` is enabled for iOS without enabling
|
||||||
`SHOREBIRD_USE_INTERPRETER` path is opt-in and disabled by default.
|
`dart_dynamic_modules`.
|
||||||
- The first `.vmcode` application library path is treated as the patch. For iOS
|
- `shorebird_use_interpreter` is enabled for iOS and the native
|
||||||
it may contain a raw Mach-O snapshot or a compact linker header followed by a
|
`shorebird_enable_aot_patching` snapshot loader is disabled.
|
||||||
Mach-O snapshot.
|
- Interpreter patch paths are not inserted into `application_library_paths`;
|
||||||
- VM snapshot symbols continue to resolve from the signed base App.framework;
|
that list is reserved for native snapshot symbol lookup.
|
||||||
only isolate data and isolate instructions are replaced by the patch.
|
- Encrypted interpreter artifacts are decrypted to data and handed to the VM
|
||||||
- The loader uses Dart's Mach-O snapshot loader on Apple platforms, so the VM
|
patch API; no downloaded native text is mapped executable.
|
||||||
does not need JIT, writable executable memory, KBC, or `DART_DYNAMIC_MODULES`.
|
- Product iOS includes the first no-DDM function replacement mapper for changed
|
||||||
|
existing functions. New libraries/classes/top-level helpers and compact
|
||||||
|
interpreter reconstruction remain future work.
|
||||||
|
- VM snapshot symbols continue to resolve from the signed base App.framework.
|
||||||
|
|
||||||
The macOS device/simulator verification pass should build the same
|
The macOS device/simulator verification pass should build the same
|
||||||
`license_flavor_patch_test` flavor pair with `--target-os=ios` and
|
`license_flavor_patch_test` flavor pair with `--target-os=ios`,
|
||||||
`--target-arch=arm64`, install the encrypted `free -> pro` patch, confirm the
|
`--target-arch=arm64`, and `--runtime-mode=dart-bytecode-interpreter`, install
|
||||||
status label changes to `license:pro` / `pro-feature:enabled`, and confirm
|
the encrypted `free -> pro` patch, confirm the status label changes to
|
||||||
wrong-key and wrong-flavor artifacts fail before the isolate starts.
|
`license:pro` / `pro-feature:enabled`, and confirm wrong-key and wrong-flavor
|
||||||
|
artifacts fail before the isolate starts. The current open runtime has the
|
||||||
|
metadata, SDK safety gate, encrypted artifact-to-bytecode handoff, no-DDM VM API
|
||||||
|
guard, and initial product-AOT replacement mapper; the compatible interpreter
|
||||||
|
payload compiler, production key callback, compact interpreter reconstruction,
|
||||||
|
and real-device behavior verification remain the next implementation steps.
|
||||||
|
|
||||||
## Obfuscation
|
## Obfuscation
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,21 @@ const payloadKindFullSnapshot = 'full-snapshot';
|
|||||||
/// Payload kind used when the artifact stores an open binary diff.
|
/// Payload kind used when the artifact stores an open binary diff.
|
||||||
const payloadKindBinaryDiff = 'binary-diff-v1';
|
const payloadKindBinaryDiff = 'binary-diff-v1';
|
||||||
|
|
||||||
|
/// Runtime mode used by today's open patch loader. The payload contains native
|
||||||
|
/// AOT snapshot instructions and therefore needs executable mapping at runtime.
|
||||||
|
const runtimeModeNativeAot = 'native-aot';
|
||||||
|
|
||||||
|
/// Runtime mode for App Store-safe iOS payloads interpreted by Dart VM code
|
||||||
|
/// already present in the app. The current open runtime requires these
|
||||||
|
/// artifacts to carry a directly loadable full bytecode snapshot.
|
||||||
|
const runtimeModeDartBytecodeInterpreter = 'dart-bytecode-interpreter';
|
||||||
|
|
||||||
|
/// Runtime modes understood by this artifact format.
|
||||||
|
const knownRuntimeModes = {
|
||||||
|
runtimeModeNativeAot,
|
||||||
|
runtimeModeDartBytecodeInterpreter,
|
||||||
|
};
|
||||||
|
|
||||||
/// Compatibility metadata bound to a compact AOT patch artifact.
|
/// Compatibility metadata bound to a compact AOT patch artifact.
|
||||||
class PatchMetadata {
|
class PatchMetadata {
|
||||||
/// Creates patch metadata for one application build and flavor.
|
/// Creates patch metadata for one application build and flavor.
|
||||||
@@ -33,6 +48,7 @@ class PatchMetadata {
|
|||||||
required this.patchSnapshotHash,
|
required this.patchSnapshotHash,
|
||||||
required this.targetOs,
|
required this.targetOs,
|
||||||
required this.targetArch,
|
required this.targetArch,
|
||||||
|
this.runtimeMode = runtimeModeNativeAot,
|
||||||
this.baseFlavorId,
|
this.baseFlavorId,
|
||||||
this.baseLicenseType,
|
this.baseLicenseType,
|
||||||
this.obfuscationMapHash,
|
this.obfuscationMapHash,
|
||||||
@@ -52,6 +68,7 @@ class PatchMetadata {
|
|||||||
patchSnapshotHash: _string(json, 'patch_snapshot_hash'),
|
patchSnapshotHash: _string(json, 'patch_snapshot_hash'),
|
||||||
targetOs: _string(json, 'target_os'),
|
targetOs: _string(json, 'target_os'),
|
||||||
targetArch: _string(json, 'target_arch'),
|
targetArch: _string(json, 'target_arch'),
|
||||||
|
runtimeMode: json['runtime_mode'] as String? ?? runtimeModeNativeAot,
|
||||||
obfuscationMapHash: json['obfuscation_map_hash'] as String?,
|
obfuscationMapHash: json['obfuscation_map_hash'] as String?,
|
||||||
offlineExpiresAt: _optionalIso8601UtcString(json, 'offline_expires_at'),
|
offlineExpiresAt: _optionalIso8601UtcString(json, 'offline_expires_at'),
|
||||||
);
|
);
|
||||||
@@ -89,6 +106,12 @@ class PatchMetadata {
|
|||||||
/// Target CPU architecture, for example `x64` or `arm64`.
|
/// Target CPU architecture, for example `x64` or `arm64`.
|
||||||
final String targetArch;
|
final String targetArch;
|
||||||
|
|
||||||
|
/// Runtime execution mode required by this patch payload.
|
||||||
|
///
|
||||||
|
/// Existing artifacts without this field are treated as
|
||||||
|
/// [runtimeModeNativeAot] for backward compatibility.
|
||||||
|
final String runtimeMode;
|
||||||
|
|
||||||
/// Optional hash of the obfuscation map used by base and patch builds.
|
/// Optional hash of the obfuscation map used by base and patch builds.
|
||||||
final String? obfuscationMapHash;
|
final String? obfuscationMapHash;
|
||||||
|
|
||||||
@@ -109,6 +132,24 @@ class PatchMetadata {
|
|||||||
return !checkedAt.isBefore(expiresAt);
|
return !checkedAt.isBefore(expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether this payload requires mapping newly supplied native instructions
|
||||||
|
/// as executable memory at runtime.
|
||||||
|
bool get requiresRuntimeExecutableMapping {
|
||||||
|
_checkKnownRuntimeMode(runtimeMode);
|
||||||
|
return runtimeMode == runtimeModeNativeAot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether this artifact is safe to install in an iOS App Store build under
|
||||||
|
/// the current open runtime model.
|
||||||
|
///
|
||||||
|
/// The current native-AOT iOS loader is useful for development and local
|
||||||
|
/// device testing, but it is not App Store-safe because it executes newly
|
||||||
|
/// supplied native snapshot text from the app data container.
|
||||||
|
bool get isIosAppStoreSafe {
|
||||||
|
_checkKnownRuntimeMode(runtimeMode);
|
||||||
|
return targetOs != 'ios' || !requiresRuntimeExecutableMapping;
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts this metadata to the stable JSON map used as AES-GCM AAD.
|
/// Converts this metadata to the stable JSON map used as AES-GCM AAD.
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
'app_id': appId,
|
'app_id': appId,
|
||||||
@@ -122,6 +163,7 @@ class PatchMetadata {
|
|||||||
'patch_snapshot_hash': patchSnapshotHash,
|
'patch_snapshot_hash': patchSnapshotHash,
|
||||||
'target_os': targetOs,
|
'target_os': targetOs,
|
||||||
'target_arch': targetArch,
|
'target_arch': targetArch,
|
||||||
|
'runtime_mode': runtimeMode,
|
||||||
if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash,
|
if (obfuscationMapHash != null) 'obfuscation_map_hash': obfuscationMapHash,
|
||||||
if (offlineExpiresAt != null) 'offline_expires_at': offlineExpiresAt,
|
if (offlineExpiresAt != null) 'offline_expires_at': offlineExpiresAt,
|
||||||
};
|
};
|
||||||
@@ -320,7 +362,11 @@ PatchArtifact linkArtifacts({
|
|||||||
required PatchMetadata metadata,
|
required PatchMetadata metadata,
|
||||||
bool forceFullSnapshot = false,
|
bool forceFullSnapshot = false,
|
||||||
}) {
|
}) {
|
||||||
final compact = forceFullSnapshot
|
_checkKnownRuntimeMode(metadata.runtimeMode);
|
||||||
|
final mustUseFullSnapshot =
|
||||||
|
forceFullSnapshot ||
|
||||||
|
metadata.runtimeMode == runtimeModeDartBytecodeInterpreter;
|
||||||
|
final compact = mustUseFullSnapshot
|
||||||
? _CompactPayload(
|
? _CompactPayload(
|
||||||
kind: payloadKindFullSnapshot,
|
kind: payloadKindFullSnapshot,
|
||||||
payload: List<int>.of(patchSnapshot),
|
payload: List<int>.of(patchSnapshot),
|
||||||
@@ -388,9 +434,11 @@ void verifyMetadata(
|
|||||||
required String licenseType,
|
required String licenseType,
|
||||||
String? baseFlavorId,
|
String? baseFlavorId,
|
||||||
String? baseLicenseType,
|
String? baseLicenseType,
|
||||||
|
bool requireIosAppStoreSafe = false,
|
||||||
DateTime? now,
|
DateTime? now,
|
||||||
bool allowExpired = false,
|
bool allowExpired = false,
|
||||||
}) {
|
}) {
|
||||||
|
_checkKnownRuntimeMode(metadata.runtimeMode);
|
||||||
if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) {
|
if (baseFlavorId != null && metadata.baseFlavorId != baseFlavorId) {
|
||||||
throw StateError(
|
throw StateError(
|
||||||
'Patch base flavor "${metadata.baseFlavorId}" does not match '
|
'Patch base flavor "${metadata.baseFlavorId}" does not match '
|
||||||
@@ -418,6 +466,19 @@ void verifyMetadata(
|
|||||||
'Patch offline license expired at ${metadata.offlineExpiresAt}.',
|
'Patch offline license expired at ${metadata.offlineExpiresAt}.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (requireIosAppStoreSafe && !metadata.isIosAppStoreSafe) {
|
||||||
|
throw StateError(
|
||||||
|
'Patch runtime mode "${metadata.runtimeMode}" for target_os '
|
||||||
|
'"${metadata.targetOs}" requires runtime executable mapping and is not '
|
||||||
|
'safe for iOS App Store delivery.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkKnownRuntimeMode(String runtimeMode) {
|
||||||
|
if (!knownRuntimeModes.contains(runtimeMode)) {
|
||||||
|
throw StateError('Unsupported patch runtime mode "$runtimeMode".');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true when a locally installed patch must be removed before startup
|
/// Returns true when a locally installed patch must be removed before startup
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ void main() {
|
|||||||
];
|
];
|
||||||
const nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
const nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||||
|
|
||||||
PatchMetadata metadata({String? offlineExpiresAt}) => PatchMetadata(
|
PatchMetadata metadata({
|
||||||
|
String? offlineExpiresAt,
|
||||||
|
String targetOs = 'windows',
|
||||||
|
String runtimeMode = runtimeModeNativeAot,
|
||||||
|
}) => PatchMetadata(
|
||||||
appId: 'app.test',
|
appId: 'app.test',
|
||||||
appBuildId: '1.0.0+1',
|
appBuildId: '1.0.0+1',
|
||||||
baseFlavorId: 'free',
|
baseFlavorId: 'free',
|
||||||
@@ -85,8 +89,9 @@ void main() {
|
|||||||
sdkHash: 'sdk',
|
sdkHash: 'sdk',
|
||||||
baseSnapshotHash: 'base',
|
baseSnapshotHash: 'base',
|
||||||
patchSnapshotHash: 'patch',
|
patchSnapshotHash: 'patch',
|
||||||
targetOs: 'windows',
|
targetOs: targetOs,
|
||||||
targetArch: 'x64',
|
targetArch: 'x64',
|
||||||
|
runtimeMode: runtimeMode,
|
||||||
obfuscationMapHash: 'obfuscated',
|
obfuscationMapHash: 'obfuscated',
|
||||||
offlineExpiresAt: offlineExpiresAt,
|
offlineExpiresAt: offlineExpiresAt,
|
||||||
);
|
);
|
||||||
@@ -283,6 +288,73 @@ void main() {
|
|||||||
expect(artifact.reconstruct(base), patch);
|
expect(artifact.reconstruct(base), patch);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('interpreter runtime keeps payload directly loadable', () {
|
||||||
|
final base = List<int>.filled(1024, 7);
|
||||||
|
final patch = List<int>.of(base)..[512] = 9;
|
||||||
|
final artifact = linkArtifacts(
|
||||||
|
baseSnapshot: base,
|
||||||
|
patchSnapshot: patch,
|
||||||
|
metadata: metadata(
|
||||||
|
targetOs: 'ios',
|
||||||
|
runtimeMode: runtimeModeDartBytecodeInterpreter,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(artifact.payloadKind, payloadKindFullSnapshot);
|
||||||
|
expect(artifact.payload, patch);
|
||||||
|
expect(artifact.reconstruct(base), patch);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iOS native AOT metadata is not App Store-safe', () {
|
||||||
|
final iosNative = metadata(targetOs: 'ios');
|
||||||
|
|
||||||
|
expect(iosNative.runtimeMode, runtimeModeNativeAot);
|
||||||
|
expect(iosNative.requiresRuntimeExecutableMapping, isTrue);
|
||||||
|
expect(iosNative.isIosAppStoreSafe, isFalse);
|
||||||
|
expect(
|
||||||
|
() => verifyMetadata(
|
||||||
|
iosNative,
|
||||||
|
flavorId: 'pro',
|
||||||
|
licenseType: 'pro',
|
||||||
|
requireIosAppStoreSafe: true,
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('iOS interpreter metadata satisfies the App Store safety gate', () {
|
||||||
|
final iosInterpreter = metadata(
|
||||||
|
targetOs: 'ios',
|
||||||
|
runtimeMode: runtimeModeDartBytecodeInterpreter,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(iosInterpreter.requiresRuntimeExecutableMapping, isFalse);
|
||||||
|
expect(iosInterpreter.isIosAppStoreSafe, isTrue);
|
||||||
|
verifyMetadata(
|
||||||
|
iosInterpreter,
|
||||||
|
flavorId: 'pro',
|
||||||
|
licenseType: 'pro',
|
||||||
|
requireIosAppStoreSafe: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dynamic modules runtime metadata is rejected', () {
|
||||||
|
final dynamicModules = metadata(runtimeMode: 'dart-dynamic-modules');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => verifyMetadata(
|
||||||
|
dynamicModules,
|
||||||
|
flavorId: 'pro',
|
||||||
|
licenseType: 'pro',
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => dynamicModules.requiresRuntimeExecutableMapping,
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('cli links encrypts verifies and dumps compact patch', () async {
|
test('cli links encrypts verifies and dumps compact patch', () async {
|
||||||
final tempDir = Directory.systemTemp.createTempSync(
|
final tempDir = Directory.systemTemp.createTempSync(
|
||||||
'open_aot_patch_tools.',
|
'open_aot_patch_tools.',
|
||||||
@@ -339,7 +411,7 @@ void main() {
|
|||||||
final fullArtifactJson = readJsonFile(artifactFile);
|
final fullArtifactJson = readJsonFile(artifactFile);
|
||||||
expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot);
|
expect(fullArtifactJson['payload_kind'], payloadKindFullSnapshot);
|
||||||
expect(
|
expect(
|
||||||
(fullArtifactJson['metadata'] as Map)['offline_expires_at'],
|
(fullArtifactJson['metadata']! as Map)['offline_expires_at'],
|
||||||
'2030-01-01T00:00:00.000Z',
|
'2030-01-01T00:00:00.000Z',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -389,6 +461,125 @@ void main() {
|
|||||||
'--base=${baseFile.path}',
|
'--base=${baseFile.path}',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await _runTool([
|
||||||
|
'link',
|
||||||
|
'--base=${baseFile.path}',
|
||||||
|
'--patch=${patchFile.path}',
|
||||||
|
'--output=${artifactFile.path}',
|
||||||
|
'--app-id=app.test',
|
||||||
|
'--app-build-id=1.0.0+1',
|
||||||
|
'--base-flavor-id=free',
|
||||||
|
'--base-license-type=free',
|
||||||
|
'--flavor-id=pro',
|
||||||
|
'--license-type=pro',
|
||||||
|
'--sdk-hash=sdk',
|
||||||
|
'--target-os=ios',
|
||||||
|
'--target-arch=arm64',
|
||||||
|
'--runtime-mode=$runtimeModeNativeAot',
|
||||||
|
'--full-snapshot=true',
|
||||||
|
]);
|
||||||
|
await _runTool([
|
||||||
|
'encrypt',
|
||||||
|
'--input=${artifactFile.path}',
|
||||||
|
'--output=${encryptedFile.path}',
|
||||||
|
'--key-id=test-key',
|
||||||
|
'--key-hex=$keyHex',
|
||||||
|
'--nonce-hex=$nonceHex',
|
||||||
|
]);
|
||||||
|
await _runToolExpectFailure([
|
||||||
|
'verify',
|
||||||
|
'--input=${encryptedFile.path}',
|
||||||
|
'--key-hex=$keyHex',
|
||||||
|
'--base-flavor-id=free',
|
||||||
|
'--base-license-type=free',
|
||||||
|
'--flavor-id=pro',
|
||||||
|
'--license-type=pro',
|
||||||
|
'--base=${baseFile.path}',
|
||||||
|
'--require-ios-app-store-safe=true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await _runToolExpectFailure([
|
||||||
|
'link',
|
||||||
|
'--base=${baseFile.path}',
|
||||||
|
'--patch=${patchFile.path}',
|
||||||
|
'--output=${artifactFile.path}',
|
||||||
|
'--app-id=app.test',
|
||||||
|
'--app-build-id=1.0.0+1',
|
||||||
|
'--base-flavor-id=free',
|
||||||
|
'--base-license-type=free',
|
||||||
|
'--flavor-id=pro',
|
||||||
|
'--license-type=pro',
|
||||||
|
'--sdk-hash=sdk',
|
||||||
|
'--target-os=ios',
|
||||||
|
'--target-arch=arm64',
|
||||||
|
'--runtime-mode=dart-dynamic-modules',
|
||||||
|
'--full-snapshot=true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await _runTool([
|
||||||
|
'link',
|
||||||
|
'--base=${baseFile.path}',
|
||||||
|
'--patch=${patchFile.path}',
|
||||||
|
'--output=${artifactFile.path}',
|
||||||
|
'--app-id=app.test',
|
||||||
|
'--app-build-id=1.0.0+1',
|
||||||
|
'--base-flavor-id=free',
|
||||||
|
'--base-license-type=free',
|
||||||
|
'--flavor-id=pro',
|
||||||
|
'--license-type=pro',
|
||||||
|
'--sdk-hash=sdk',
|
||||||
|
'--target-os=ios',
|
||||||
|
'--target-arch=arm64',
|
||||||
|
'--runtime-mode=$runtimeModeDartBytecodeInterpreter',
|
||||||
|
]);
|
||||||
|
final interpreterArtifactJson = readJsonFile(artifactFile);
|
||||||
|
expect(interpreterArtifactJson['payload_kind'], payloadKindFullSnapshot);
|
||||||
|
await _runTool([
|
||||||
|
'encrypt',
|
||||||
|
'--input=${artifactFile.path}',
|
||||||
|
'--output=${encryptedFile.path}',
|
||||||
|
'--key-id=test-key',
|
||||||
|
'--key-hex=$keyHex',
|
||||||
|
'--nonce-hex=$nonceHex',
|
||||||
|
]);
|
||||||
|
await _runTool([
|
||||||
|
'verify',
|
||||||
|
'--input=${encryptedFile.path}',
|
||||||
|
'--key-hex=$keyHex',
|
||||||
|
'--base-flavor-id=free',
|
||||||
|
'--base-license-type=free',
|
||||||
|
'--flavor-id=pro',
|
||||||
|
'--license-type=pro',
|
||||||
|
'--base=${baseFile.path}',
|
||||||
|
'--require-ios-app-store-safe=true',
|
||||||
|
]);
|
||||||
|
|
||||||
|
await _runTool([
|
||||||
|
'link',
|
||||||
|
'--base=${baseFile.path}',
|
||||||
|
'--patch=${patchFile.path}',
|
||||||
|
'--output=${artifactFile.path}',
|
||||||
|
'--app-id=app.test',
|
||||||
|
'--app-build-id=1.0.0+1',
|
||||||
|
'--base-flavor-id=free',
|
||||||
|
'--base-license-type=free',
|
||||||
|
'--flavor-id=pro',
|
||||||
|
'--license-type=pro',
|
||||||
|
'--sdk-hash=sdk',
|
||||||
|
'--target-os=windows',
|
||||||
|
'--target-arch=x64',
|
||||||
|
'--full-snapshot=true',
|
||||||
|
'--offline-expires-at=2030-01-01T00:00:00Z',
|
||||||
|
]);
|
||||||
|
await _runTool([
|
||||||
|
'encrypt',
|
||||||
|
'--input=${artifactFile.path}',
|
||||||
|
'--output=${encryptedFile.path}',
|
||||||
|
'--key-id=test-key',
|
||||||
|
'--key-hex=$keyHex',
|
||||||
|
'--nonce-hex=$nonceHex',
|
||||||
|
]);
|
||||||
|
|
||||||
final dump = await _runTool([
|
final dump = await _runTool([
|
||||||
'dump-blobs',
|
'dump-blobs',
|
||||||
'--input=${encryptedFile.path}',
|
'--input=${encryptedFile.path}',
|
||||||
|
|||||||
Reference in New Issue
Block a user