From d2084b64646432fb99feeb5f91abba5e018eb2fb Mon Sep 17 00:00:00 2001 From: Mark Vasiv Date: Fri, 17 Jul 2026 13:57:08 +0200 Subject: [PATCH] generate App Intents SSU training assets during bundling --- apple/internal/BUILD | 1 + apple/internal/codesigning_support.bzl | 12 +++- apple/internal/ios_rules.bzl | 1 + .../partials/app_intents_metadata_bundle.bzl | 16 +++++ apple/internal/processor.bzl | 29 ++++++++- apple/internal/resource_actions/BUILD | 2 + .../internal/resource_actions/app_intents.bzl | 61 +++++++++++++++++++ doc/common_info.md | 32 ++++++++++ tools/bundletool/bundletool_experimental.py | 38 ++++++++++++ tools/bundletool/process_and_sign.sh.template | 5 ++ 10 files changed, 193 insertions(+), 4 deletions(-) diff --git a/apple/internal/BUILD b/apple/internal/BUILD index 688258d413..375dc82dce 100644 --- a/apple/internal/BUILD +++ b/apple/internal/BUILD @@ -534,6 +534,7 @@ bzl_library( ":experimental", ":intermediates", ":outputs", + "//apple/internal/resource_actions:app_intents", "//apple/internal/utils:bundle_paths", "//apple/internal/utils:defines", "@apple_support//lib:apple_support", diff --git a/apple/internal/codesigning_support.bzl b/apple/internal/codesigning_support.bzl index f70e9d3b6f..428144bed8 100644 --- a/apple/internal/codesigning_support.bzl +++ b/apple/internal/codesigning_support.bzl @@ -595,7 +595,8 @@ def _post_process_and_sign_archive_action( process_and_sign_template, provisioning_profile, rule_descriptor, - signed_frameworks): + signed_frameworks, + ssu_training_commands = None): """Post-processes and signs an archived bundle. Args: @@ -621,6 +622,8 @@ def _post_process_and_sign_archive_action( provisioning_profile: The provisioning profile file. May be `None`. rule_descriptor: A rule descriptor for platform and product types from the rule context. signed_frameworks: Depset containing each framework that has already been signed. + ssu_training_commands: Shell command lines that generate App Intents SSU (NL + training) assets on the assembled bundle before signing. May be `None`. """ input_files = [input_archive] processing_tools = [] @@ -678,7 +681,7 @@ def _post_process_and_sign_archive_action( # If there is no work to be done, skip the processing/signing action, just # copy the file over. - has_work = any([signing_command_lines, ipa_post_processor_path, should_compress]) + has_work = any([signing_command_lines, ipa_post_processor_path, should_compress, ssu_training_commands]) if not has_work: actions.run_shell( command = "cp -p '%s' '%s'" % (input_archive.path, output_archive.path), @@ -701,6 +704,7 @@ def _post_process_and_sign_archive_action( is_executable = True, substitutions = { "%ipa_post_processor%": shell.quote(ipa_post_processor_path) or "", + "%ssu_training_command_lines%": ssu_training_commands or "", "%output_path%": shell.quote(output_archive.path), "%should_compress%": "1" if should_compress else "", "%should_verify%": "1", # always verify the crc @@ -719,8 +723,10 @@ def _post_process_and_sign_archive_action( arguments.append("should_process") if should_compress: arguments.append("should_compress") + if ssu_training_commands: + arguments.append("should_generate_ssu_assets") - run_on_darwin = any([signing_command_lines, ipa_post_processor_path]) + run_on_darwin = any([signing_command_lines, ipa_post_processor_path, ssu_training_commands]) if run_on_darwin: apple_support.run( actions = actions, diff --git a/apple/internal/ios_rules.bzl b/apple/internal/ios_rules.bzl index 4ed932c2df..b0170ac33d 100644 --- a/apple/internal/ios_rules.bzl +++ b/apple/internal/ios_rules.bzl @@ -289,6 +289,7 @@ def _ios_application_impl(ctx): ), partials.app_intents_metadata_bundle_partial( actions = actions, + bundle_id = bundle_id, cc_toolchains = cc_toolchain_forwarder, deps = ctx.split_attr.app_intents, label = label, diff --git a/apple/internal/partials/app_intents_metadata_bundle.bzl b/apple/internal/partials/app_intents_metadata_bundle.bzl index fe79ddd062..9f86021617 100644 --- a/apple/internal/partials/app_intents_metadata_bundle.bzl +++ b/apple/internal/partials/app_intents_metadata_bundle.bzl @@ -29,6 +29,7 @@ load( def _app_intents_metadata_bundle_partial_impl( *, actions, + bundle_id = None, cc_toolchains, deps, label, @@ -77,7 +78,16 @@ def _app_intents_metadata_bundle_partial_impl( if str(platform_prerequisites.platform_type) == "macos": bundle_location = processor.location.resource + # Request SSU (NL training) asset generation during bundling, mirroring Xcode's + # AppIntentsSSUTraining build phase. Requires the assembled bundle, so it is + # deferred to the bundling/signing actions rather than run here. Currently + # opt-in via the `apple.app_intents_ssu_training` feature while being validated. + app_intents_ssu_training = None + if bundle_id and "apple.app_intents_ssu_training" in platform_prerequisites.features: + app_intents_ssu_training = struct(bundle_id = bundle_id) + return struct( + app_intents_ssu_training = app_intents_ssu_training, bundle_files = [( bundle_location, "Metadata.appintents", @@ -88,6 +98,7 @@ def _app_intents_metadata_bundle_partial_impl( def app_intents_metadata_bundle_partial( *, actions, + bundle_id = None, cc_toolchains, deps, label, @@ -99,6 +110,10 @@ def app_intents_metadata_bundle_partial( Args: actions: The actions provider from ctx.actions. + bundle_id: The bundle identifier of the bundle being processed. If set and the + `apple.app_intents_ssu_training` feature is enabled, SSU (NL training) + assets will also be generated for the bundle during the bundling/signing + actions. cc_toolchains: Dictionary of CcToolchainInfo and ApplePlatformInfo providers under a split transition to relay target platform information. deps: Dictionary of targets under a split transition implementing the AppIntents protocol. @@ -113,6 +128,7 @@ def app_intents_metadata_bundle_partial( return partial.make( _app_intents_metadata_bundle_partial_impl, actions = actions, + bundle_id = bundle_id, cc_toolchains = cc_toolchains, deps = deps, label = label, diff --git a/apple/internal/processor.bzl b/apple/internal/processor.bzl index 2828f66237..ae9f5cec87 100644 --- a/apple/internal/processor.bzl +++ b/apple/internal/processor.bzl @@ -91,6 +91,10 @@ load( "//apple/internal:outputs.bzl", "outputs", ) +load( + "//apple/internal/resource_actions:app_intents.bzl", + "app_intents_ssu_training_commands", +) load( "//apple/internal/utils:bundle_paths.bzl", "bundle_paths", @@ -243,7 +247,8 @@ def _bundle_partial_outputs_files( partial_outputs, platform_prerequisites, provisioning_profile, - rule_descriptor): + rule_descriptor, + ssu_training_commands = None): """Invokes bundletool to bundle the files specified by the partial outputs. Args: @@ -269,6 +274,9 @@ def _bundle_partial_outputs_files( platform_prerequisites: Struct containing information on the platform being targeted. provisioning_profile: File for the provisioning profile. rule_descriptor: A rule descriptor for platform and product types from the rule context. + ssu_training_commands: When building tree artifact outputs, shell command lines + that generate App Intents SSU (NL training) assets on the assembled bundle + before it is signed. May be `None`. """ # Autotrim locales here only if the rule supports it and there weren't requested locales. @@ -389,6 +397,7 @@ def _bundle_partial_outputs_files( bundle_merge_zips = control_zips, output = output_file.path, code_signing_commands = codesigning_command or "", + ssu_training_commands = ssu_training_commands or "", post_processor = post_processor_path, ) @@ -530,6 +539,22 @@ def _bundle_post_process_and_sign( signed_frameworks_depsets.append(partial_output.signed_frameworks) transitive_signed_frameworks = depset(transitive = signed_frameworks_depsets) + # SSU (NL training) asset generation requested by the AppIntents metadata bundle + # partial. It requires the assembled bundle, so it runs within the bundling/signing + # actions, before codesigning. + app_intents_ssu_training = None + for partial_output in partial_outputs: + if getattr(partial_output, "app_intents_ssu_training", None): + app_intents_ssu_training = partial_output.app_intents_ssu_training + + ssu_training_commands = None + if app_intents_ssu_training: + ssu_training_commands = app_intents_ssu_training_commands( + bundle_id = app_intents_ssu_training.bundle_id, + bundle_path = archive_paths[_LOCATION_ENUM.bundle], + resources_path = archive_paths[_LOCATION_ENUM.resource], + ) + if tree_artifact_is_enabled: extra_input_files = [] @@ -565,6 +590,7 @@ def _bundle_post_process_and_sign( ipa_post_processor = ipa_post_processor, label_name = rule_label.name, locales_to_include = locales_to_include, + ssu_training_commands = ssu_training_commands, output_discriminator = output_discriminator, output_file = output_archive, partial_outputs = partial_outputs, @@ -621,6 +647,7 @@ def _bundle_post_process_and_sign( input_archive = unprocessed_archive, ipa_post_processor = ipa_post_processor, label_name = rule_label.name, + ssu_training_commands = ssu_training_commands, output_archive = output_archive, output_archive_root_path = output_archive_root_path, output_discriminator = output_discriminator, diff --git a/apple/internal/resource_actions/BUILD b/apple/internal/resource_actions/BUILD index dbdc698017..0189f7f7f1 100644 --- a/apple/internal/resource_actions/BUILD +++ b/apple/internal/resource_actions/BUILD @@ -28,12 +28,14 @@ bzl_library( name = "app_intents", srcs = ["app_intents.bzl"], visibility = [ + "//apple/internal:__pkg__", "//apple/internal/partials:__pkg__", ], deps = [ "//apple/internal:intermediates", "//apple/internal:shared_environment", "@apple_support//lib:apple_support", + "@bazel_skylib//lib:paths", ], ) diff --git a/apple/internal/resource_actions/app_intents.bzl b/apple/internal/resource_actions/app_intents.bzl index f1026bf458..fbcc7c73d4 100644 --- a/apple/internal/resource_actions/app_intents.bzl +++ b/apple/internal/resource_actions/app_intents.bzl @@ -15,6 +15,7 @@ """AppIntents intents related actions.""" load("@apple_support//lib:apple_support.bzl", "apple_support") +load("@bazel_skylib//lib:paths.bzl", "paths") load("//apple/internal:intermediates.bzl", "intermediates") load("//apple/internal:shared_environment.bzl", "shared_environment") @@ -186,3 +187,63 @@ fi ) return output + +def app_intents_ssu_training_commands( + *, + bundle_id, + contents_path, + resources_path): + """Returns shell command lines that generate App Intents SSU (NL training) assets. + + Mirrors Xcode's AppIntentsSSUTraining build phase, which runs + appintentsnltrainingprocessor on the assembled product to generate + Metadata.appintents/root.ssu.yaml and the per-locale .lproj/nlu.appintents + archives. Without these assets, Siri's assistant schema routing and App Shortcuts + utterances do not recognize the app. + + The returned commands must be executed on an assembled (but not yet codesigned) + bundle, in an environment where $WORK_DIR points to the archive root and DEVELOPER_DIR + is set (i.e. within the bundling/signing actions), so that the generated assets are + sealed by the code signature. + + Args: + bundle_id: The bundle identifier of the bundle being processed. + contents_path: Path to the bundle's contents directory, relative to the archive + root. Equal to the bundle root except on macOS, where it is `Contents`. + resources_path: Path to the bundle's resources directory, relative to the + archive root. Equal to the bundle root except on macOS, where it is + `Contents/Resources`. + + Returns: + A string with the shell command lines to execute. + """ + contents_dir = paths.join("$WORK_DIR", contents_path) if contents_path else "$WORK_DIR" + product_dir = paths.join("$WORK_DIR", resources_path) if resources_path else "$WORK_DIR" + # The tool may report failures on its output while still exiting with 0 (e.g. + # "error: Could not archive SSU artifacts"), so inspect the output for errors + # like the AppIntentsMetadataProcessor action does. + return """\ +if [[ -d "{product_dir}/Metadata.appintents" ]] && \\ + xcrun --find appintentsnltrainingprocessor >/dev/null 2>&1; then + ssu_temp_dir="$(mktemp -d)" + chmod u+w "{product_dir}" "{product_dir}/Metadata.appintents" + find "{product_dir}" -maxdepth 1 -type d -name "*.lproj" -exec chmod u+w {{}} + + ssu_exit_status=0 + ssu_output=$(xcrun appintentsnltrainingprocessor \\ + --infoplist-path "{contents_dir}/Info.plist" \\ + --temp-dir-path "$ssu_temp_dir" \\ + --bundle-id "{bundle_id}" \\ + --product-path "{product_dir}" \\ + --extracted-metadata-path "{product_dir}/Metadata.appintents" \\ + --archive-ssu-assets 2>&1) || ssu_exit_status=$? + rm -rf "$ssu_temp_dir" + if [[ "$ssu_exit_status" -ne 0 || "$ssu_output" == *error:* ]]; then + echo "$ssu_output" >&2 + exit 1 + fi +fi +""".format( + bundle_id = bundle_id, + contents_dir = contents_dir, + product_dir = product_dir, + ) diff --git a/doc/common_info.md b/doc/common_info.md index b12ca32141..2722309c16 100644 --- a/doc/common_info.md +++ b/doc/common_info.md @@ -235,6 +235,38 @@ ios_unit_test( ) ``` +### App Intents SSU Training Assets {#apple.app_intents_ssu_training} + +Xcode runs an `AppIntentsSSUTraining` build phase after App Intents metadata +extraction, invoking `appintentsnltrainingprocessor` on the built product to +generate natural-language training assets: `Metadata.appintents/root.ssu.yaml` +and one compiled `.lproj/nlu.appintents` archive per localization. +Siri requires these assets to recognize App Shortcut phrases and, on iOS 26 +and later, to route assistant schema requests (e.g. +`@AssistantIntent(schema: .media.playAudio)`) to the app. Without them, Siri +reports the app as not supporting the corresponding App Intents and falls back +to just launching it. + +When this feature is enabled, applications that set `app_intents` generate +these assets during bundling, before codesigning: + +```shell +bazel build --features=apple.app_intents_ssu_training //your/app +``` + +or on a per-target basis: + +```bzl +ios_application( + ... + app_intents = [":intents_lib"], + features = ["apple.app_intents_ssu_training"], +) +``` + +This step requires an Xcode toolchain that provides +`appintentsnltrainingprocessor`; it is skipped when the tool is not available. + ### Codesigning performance For larger applications, codesigning the final binary might be a diff --git a/tools/bundletool/bundletool_experimental.py b/tools/bundletool/bundletool_experimental.py index c66bd7e86b..5d625128a4 100644 --- a/tools/bundletool/bundletool_experimental.py +++ b/tools/bundletool/bundletool_experimental.py @@ -40,6 +40,9 @@ contents should be placed. code_signing_commands: An optional list of shell commands that should be executed to sign the bundle. + ssu_training_commands: An optional string of shell command lines that generate + App Intents SSU (NL training) assets, executed after the bundle is + complete and post-processed but before it is signed. output: The path to the directory (which will be created/cleared) that will represent the complete bundle. post_processor: The optional path to an executable that will be run after the @@ -85,6 +88,9 @@ def _load_clonefile(): POST_PROCESSOR_ERROR_MSG_TEMPLATE = 'Post processor failed with exit code %d' +SSU_TRAINING_ERROR_MSG_TEMPLATE = ( + 'App Intents SSU training failed with exit code %d') + class BundleConflictError(ValueError): """Raised when two different files would be bundled in the same location.""" @@ -133,6 +139,15 @@ def __init__(self, exit_code): POST_PROCESSOR_ERROR_MSG_TEMPLATE % exit_code) +class SSUTrainingError(EnvironmentError): + """Raised if the App Intents SSU training commands fail.""" + + def __init__(self, exit_code): + self.exit_code = exit_code + EnvironmentError.__init__(self, + SSU_TRAINING_ERROR_MSG_TEMPLATE % exit_code) + + class Bundler(object): """Implements the core functionality of the bundler.""" @@ -172,6 +187,10 @@ def run(self): if post_processor: self._post_process_bundle(output_path, post_processor) + ssu_training_commands = self._control.get('ssu_training_commands') + if ssu_training_commands: + self._generate_ssu_training_assets(output_path, ssu_training_commands) + code_signing_commands = self._control.get('code_signing_commands') if code_signing_commands: self._sign_bundle(output_path, code_signing_commands) @@ -389,6 +408,25 @@ def _post_process_bundle(self, bundle_root, post_processor): except subprocess.CalledProcessError as e: raise PostProcessorError(e.returncode) from e + def _generate_ssu_training_assets(self, bundle_root, command_lines): + """Executes the App Intents SSU training command lines on the bundle. + + Args: + bundle_root: The path to the bundle. + command_lines: The shell command lines that should be executed on the + bundle to generate the SSU (NL training) assets. They reference the + bundle via a WORK_DIR environment variable and require the action's + environment (notably DEVELOPER_DIR) to locate the Xcode toolchain. + """ + env = dict(os.environ) + env['WORK_DIR'] = bundle_root + try: + subprocess.check_call( + ['/bin/bash', '-e', '-u', '-o', 'pipefail', '-c', command_lines], + env=env) + except subprocess.CalledProcessError as e: + raise SSUTrainingError(e.returncode) from e + def _sign_bundle(self, bundle_root, command_lines): """Executes the signing command lines on the bundle. diff --git a/tools/bundletool/process_and_sign.sh.template b/tools/bundletool/process_and_sign.sh.template index ec387373ef..9c25b0b4b3 100644 --- a/tools/bundletool/process_and_sign.sh.template +++ b/tools/bundletool/process_and_sign.sh.template @@ -38,6 +38,11 @@ if [[ -n "$POST_PROCESSOR" ]]; then "$POST_PROCESSOR" "$WORK_DIR" fi +# Generate App Intents SSU (NL training) assets, if requested. This may expand +# to nothing. It must run before signing so the generated assets are sealed by +# the code signature. +%ssu_training_command_lines% + # Sign the application, if requested. This may expand to nothing in the case # where signing is not being performed. %signing_command_lines%