From 64c9e72fad8a2291f25647f77e9e4b484b5a3dd9 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Tue, 26 May 2026 23:11:36 +0200 Subject: [PATCH] Embed xcodebuild-built tgwatch app into the Bazel Telegram IPA Add the apple_prebuilt_watchos_application rule (prebuilt_watchos.bzl + its worker) that runs xcodebuild on the watch app, codesigns the .app and nested framework, and feeds it to the Telegram ios_application's watch_application slot, gated on //Telegram:embedWatchApp. Make.py gains device-gated watch embed flags (--watchApiId/--watchApiHash/--watchSigningIdentity/ --watchProvisioningProfile), and the embedded app's version is matched to the host (versions.json + buildNumber). Co-Authored-By: Claude Opus 4.7 (1M context) --- Telegram/BUILD | 30 +++++++ Telegram/prebuilt_watchos.bzl | 127 +++++++++++++++++++++++++++++ Telegram/prebuilt_watchos_build.sh | 90 ++++++++++++++++++++ build-system/Make/Make.py | 72 +++++++++++++++- 4 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 Telegram/prebuilt_watchos.bzl create mode 100644 Telegram/prebuilt_watchos_build.sh diff --git a/Telegram/BUILD b/Telegram/BUILD index 22655f3a9b..bc21da13a8 100644 --- a/Telegram/BUILD +++ b/Telegram/BUILD @@ -35,6 +35,10 @@ load( load("@build_bazel_rules_apple//apple:apple.bzl", "local_provisioning_profile") +load("//Telegram:prebuilt_watchos.bzl", + "apple_prebuilt_watchos_application", +) + load("//build-system/bazel-utils:plist_fragment.bzl", "plist_fragment", ) @@ -97,6 +101,19 @@ config_setting( }, ) +bool_flag( + name = "embedWatchApp", + build_setting_default = False, + visibility = ["//visibility:public"], +) + +config_setting( + name = "embedWatchAppSetting", + flag_values = { + ":embedWatchApp": "True", + }, +) + config_setting( name = "projectIncludeReleaseSetting", flag_values = { @@ -1724,6 +1741,11 @@ xcode_provisioning_profile( provisioning_profile = ":Telegram_local_profile", ) +apple_prebuilt_watchos_application( + name = "TelegramWatchApp", + tags = ["manual"], +) + ios_application( name = "Telegram", bundle_id = "{telegram_bundle_id}".format( @@ -1772,6 +1794,14 @@ ios_application( ":BroadcastUploadExtension", ], }), + # Embed the xcodebuild-built tgwatch watch app under Watch/ only when + # --//Telegram:embedWatchApp is set (Make.py sets it for device configs when + # --watchAppSourcePath is provided). Off by default, so simulator/dev builds + # are unaffected. + watch_application = select({ + ":embedWatchAppSetting": ":TelegramWatchApp", + "//conditions:default": None, + }), deps = [ ":Main", ":Lib", diff --git a/Telegram/prebuilt_watchos.bzl b/Telegram/prebuilt_watchos.bzl new file mode 100644 index 0000000000..4fd6b16563 --- /dev/null +++ b/Telegram/prebuilt_watchos.bzl @@ -0,0 +1,127 @@ +"""Embeds the standalone, xcodebuild-built tgwatch watch app into the Bazel iOS build. + +`apple_prebuilt_watchos_application` runs `xcodebuild` (via prebuilt_watchos_build.sh) +against an exported tgwatch source tree, optionally codesigns the result, and exposes +it through the providers that `ios_application(watch_application = ...)` consumes: + + * AppleBundleInfo — bundle metadata (the host reads only `.product_type`). + * AppleEmbeddableInfo — `watch_bundles` (the zipped .app placed under Watch/). + +The watch source tree lives at an external, machine-specific absolute path passed via +`--define=watchAppSourcePath=...`; the build action is therefore local/uncached. + +Notes on the rules_apple providers used here: + * AppleBundleInfo's public init is banned; we build it with the internal raw + initializer `new_applebundleinfo` (rules_apple is vendored + pinned in this repo, + so depending on the internal label is safe). +""" + +load( + "@build_bazel_rules_apple//apple/internal:providers.bzl", + "new_applebundleinfo", + "new_watchosapplicationbundleinfo", +) +load("@build_bazel_rules_apple//apple/internal/providers:embeddable_info.bzl", "AppleEmbeddableInfo") + +def _apple_prebuilt_watchos_application_impl(ctx): + source_path = ctx.var.get("watchAppSourcePath", "") + if not source_path: + fail("apple_prebuilt_watchos_application requires --define=watchAppSourcePath=") + api_id = ctx.var.get("watchApiId", "0") + api_hash = ctx.var.get("watchApiHash", "placeholder") + identity = ctx.var.get("watchSigningIdentity", "") + + # The provisioning profile is an external, machine-specific absolute path + # (like watchAppSourcePath), passed via --define rather than a Bazel label so + # the gitignored profile need not be exposed as a target. The local action + # reads it directly. Empty => unsigned build. + profile = ctx.var.get("watchProvisioningProfile", "") + # The embedded watch app's CFBundleShortVersionString / CFBundleVersion must match + # the host app, or rules_apple's child-version verification fails. Source the + # marketing version from versions.json (same as the host's VersionInfoPlist) and the + # build version from buildNumber (Make.py always emits --define=buildNumber). + build_number = ctx.var.get("buildNumber", "1") + archive = ctx.actions.declare_file(ctx.label.name + ".zip") + # The host ios_application reads the watch app's Info.plist (via AppleBundleInfo.infoplist) + # to verify WKCompanionAppBundleIdentifier against the host bundle id, so expose it as a + # separate output (resources.bzl bundle_verification crashes on a None infoplist). + infoplist = ctx.actions.declare_file(ctx.label.name + "_Info.plist") + + ctx.actions.run( + executable = "/bin/bash", + arguments = [ + ctx.file._worker.path, + source_path, + archive.path, + api_id, + api_hash, + identity, + profile, + infoplist.path, + ctx.file.versions_json.path, + build_number, + ], + inputs = [ctx.file._worker, ctx.file.versions_json], + outputs = [archive, infoplist], + mnemonic = "PrebuiltWatchosBuild", + progress_message = "Building%s watch app via xcodebuild" % (" + signing" if identity else ""), + # The watch source tree is an external absolute path, not a tracked input, + # so the action cannot be cached or sandboxed and may fetch SwiftPM deps. + execution_requirements = { + "no-cache": "1", + "no-sandbox": "1", + "no-remote": "1", + "local": "1", + "requires-network": "1", + }, + use_default_shell_env = True, + ) + + return [ + DefaultInfo(files = depset([archive])), + new_applebundleinfo( + archive = archive, + bundle_id = ctx.attr.bundle_id, + bundle_name = ctx.attr.bundle_name, + bundle_extension = ".app", + platform_type = "watchos", + # Must be a single-target watchOS app (NOT watch2_application) so the host + # skips the watchos_stub partial (see ios_rules.bzl product_type check). + product_type = "com.apple.product-type.application", + minimum_os_version = ctx.attr.minimum_os_version, + minimum_deployment_os_version = ctx.attr.minimum_os_version, + infoplist = infoplist, + binary = None, + entitlements = None, + # Best-effort constant; the host ios_application reads only product_type. + uses_swift = True, + extension_safe = False, + ), + # Marker provider required by ios_application's watch_application attr + # (providers = [[AppleBundleInfo, WatchosApplicationBundleInfo]]). + new_watchosapplicationbundleinfo(), + AppleEmbeddableInfo( + # The signed (or unsigned) .app archive, expanded into the host's Watch/ section. + watch_bundles = depset([archive]), + # Empty: the worker signs everything inside the watch app itself. + signed_frameworks = depset(), + ), + ] + +apple_prebuilt_watchos_application = rule( + implementation = _apple_prebuilt_watchos_application_impl, + attrs = { + "bundle_id": attr.string(default = "ph.telegra.Telegraph.watchkitapp"), + "bundle_name": attr.string(default = "tgwatch Watch App"), + "minimum_os_version": attr.string(default = "26.0"), + "versions_json": attr.label( + allow_single_file = True, + default = "//:versions.json", + doc = "Source of the marketing version (key 'app'), kept in sync with the host app.", + ), + "_worker": attr.label( + default = "//Telegram:prebuilt_watchos_build.sh", + allow_single_file = True, + ), + }, +) diff --git a/Telegram/prebuilt_watchos_build.sh b/Telegram/prebuilt_watchos_build.sh new file mode 100644 index 0000000000..1c177b9a2c --- /dev/null +++ b/Telegram/prebuilt_watchos_build.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Worker for the apple_prebuilt_watchos_application Bazel rule. +# +# Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED), then +# — if a signing identity is supplied — codesigns the app and its nested +# frameworks with the Telegram distribution/development identity + the watchkitapp +# provisioning profile, and finally zips the .app into the rule's output archive. +# +# The host ios_application embeds this archive under Watch/ and re-seals the host; +# it does NOT re-sign the watch app, so the watch signing must happen here. +# +# Args: +# $1 source_path Absolute path to the exported tgwatch source tree (contains tgwatch.xcodeproj) +# $2 output_zip Path (declared by Bazel) to write the .app archive to +# $3 api_id TG_API_ID build setting +# $4 api_hash TG_API_HASH build setting +# $5 identity Codesigning identity (SHA1 hash); empty => unsigned build +# $6 profile Path to the watchkitapp .mobileprovision; empty => none +set -euo pipefail + +SRC="$1"; OUT_ZIP="$2"; API_ID="$3"; API_HASH="$4"; IDENTITY="${5:-}"; PROFILE="${6:-}"; INFOPLIST_OUT="${7:-}"; VERSIONS_JSON="${8:-}"; BUILD_NUMBER="${9:-1}" + +if [ ! -e "$SRC/tgwatch.xcodeproj" ]; then + echo "error: no tgwatch.xcodeproj at watchAppSourcePath=$SRC (run tools/export-sources.sh first)" >&2 + exit 1 +fi + +# Match the host app's version (rules_apple requires the embedded watch app's +# CFBundleShortVersionString/CFBundleVersion to equal the parent's). +MARKETING_VERSION="0.1" +if [ -n "$VERSIONS_JSON" ]; then + MARKETING_VERSION="$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['app'])" "$VERSIONS_JSON")" +fi + +DD="$(mktemp -d)" +trap 'rm -rf "$DD"' EXIT + +xcodebuild \ + -project "$SRC/tgwatch.xcodeproj" \ + -scheme "tgwatch Watch App" \ + -configuration Release \ + -destination 'generic/platform=watchOS' \ + -derivedDataPath "$DD" \ + -quiet \ + CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="" \ + TG_API_ID="$API_ID" TG_API_HASH="$API_HASH" \ + MARKETING_VERSION="$MARKETING_VERSION" CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \ + build 1>&2 + +APP="$(find "$DD/Build/Products" -maxdepth 2 -name 'tgwatch Watch App.app' -type d | head -1)" +if [ -z "$APP" ]; then + echo "error: built watch .app not found under $DD/Build/Products" >&2 + exit 1 +fi + +# Expose the watch app's Info.plist (the host reads it to verify the companion +# bundle-id linkage). Codesigning does not alter Info.plist content, so capture it now. +if [ -n "$INFOPLIST_OUT" ]; then + cp "$APP/Info.plist" "$INFOPLIST_OUT" +fi + +if [ -n "$IDENTITY" ]; then + if [ -z "$PROFILE" ]; then + echo "error: a signing identity was given but no provisioning profile (set --define=watchProvisioningProfile=)" >&2 + exit 1 + fi + cp "$PROFILE" "$APP/embedded.mobileprovision" + ENT="$(mktemp)" + trap 'rm -rf "$DD" "$ENT" "$ENT.plist"' EXIT + security cms -D -i "$APP/embedded.mobileprovision" > "$ENT.plist" + if ! /usr/libexec/PlistBuddy -x -c 'Print :Entitlements' "$ENT.plist" > "$ENT" 2>/dev/null; then + echo "error: provisioning profile has no Entitlements key: $PROFILE" >&2 + exit 1 + fi + # Sign inside-out: nested frameworks first, then the app bundle. + if [ -d "$APP/Frameworks" ]; then + for fw in "$APP/Frameworks/"*; do + [ -e "$fw" ] || continue + codesign --force --timestamp=none --sign "$IDENTITY" "$fw" 1>&2 + done + fi + codesign --force --timestamp=none --sign "$IDENTITY" --entitlements "$ENT" "$APP" 1>&2 + codesign --verify --deep --strict "$APP" 1>&2 +fi + +# $OUT_ZIP is execroot-relative; the action's cwd is the execroot, so do NOT cd +# (that would resolve $OUT_ZIP against the DerivedData dir). --keepParent makes the +# archive root the .app itself even when $APP is an absolute path. +rm -f "$OUT_ZIP" +/usr/bin/ditto -c -k --keepParent "$APP" "$OUT_ZIP" diff --git a/build-system/Make/Make.py b/build-system/Make/Make.py index 457e5d9517..3404771fdd 100644 --- a/build-system/Make/Make.py +++ b/build-system/Make/Make.py @@ -47,6 +47,11 @@ class BazelCommandLine: self.enable_sandbox = False self.disable_provisioning_profiles = False self.profile_swift = False + self.watch_app_source_path = None + self.watch_api_id = None + self.watch_api_hash = None + self.watch_signing_identity = None + self.watch_provisioning_profile = None self.common_args = [ # https://docs.bazel.build/versions/master/command-line-reference.html @@ -190,6 +195,13 @@ class BazelCommandLine: else: raise Exception('Unknown configuration {}'.format(configuration)) + def set_watch_app(self, source_path, api_id, api_hash, signing_identity, provisioning_profile): + self.watch_app_source_path = source_path + self.watch_api_id = api_id + self.watch_api_hash = api_hash + self.watch_signing_identity = signing_identity + self.watch_provisioning_profile = provisioning_profile + def get_startup_bazel_arguments(self): combined_arguments = [] if self.bazel_user_root is not None: @@ -210,9 +222,19 @@ class BazelCommandLine: call_executable(combined_arguments) def get_define_arguments(self): - return [ + args = [ '--define=buildNumber={}'.format(self.build_number), ] + if self.watch_app_source_path is not None: + args += [ + '--define=watchAppSourcePath={}'.format(self.watch_app_source_path), + '--define=watchApiId={}'.format(self.watch_api_id), + '--define=watchApiHash={}'.format(self.watch_api_hash), + '--define=watchSigningIdentity={}'.format(self.watch_signing_identity or ''), + '--define=watchProvisioningProfile={}'.format(self.watch_provisioning_profile or ''), + '--//Telegram:embedWatchApp', + ] + return args def get_project_generation_arguments(self): combined_arguments = [] @@ -609,6 +631,19 @@ def build(bazel, arguments): ) bazel_command_line.set_configuration(arguments.configuration) + if arguments.watchAppSourcePath is not None: + if arguments.configuration in ('debug_arm64', 'release_arm64'): + if arguments.watchApiId is None or arguments.watchApiHash is None: + raise Exception('--watchAppSourcePath requires --watchApiId and --watchApiHash (the embedded watch app build needs API credentials).') + bazel_command_line.set_watch_app( + arguments.watchAppSourcePath, + arguments.watchApiId, + arguments.watchApiHash, + arguments.watchSigningIdentity, + arguments.watchProvisioningProfile + ) + else: + print('TelegramBuild: warning: --watchAppSourcePath requires a device configuration (debug_arm64 or release_arm64); watch embedding is disabled for simulator builds.') bazel_command_line.set_build_number(arguments.buildNumber) bazel_command_line.set_custom_target(arguments.target) bazel_command_line.set_continue_on_error(arguments.continueOnError) @@ -1009,6 +1044,41 @@ if __name__ == '__main__': default=False, help='Respect MODULE.bazel.lock.' ) + buildParser.add_argument( + '--watchAppSourcePath', + required=False, + type=str, + help='Absolute path to the exported tgwatch source tree. When provided with a device configuration, embeds the prebuilt watch app.', + metavar='path' + ) + buildParser.add_argument( + '--watchApiId', + required=False, + type=str, + help='TG_API_ID for the watch build.', + metavar='api_id' + ) + buildParser.add_argument( + '--watchApiHash', + required=False, + type=str, + help='TG_API_HASH for the watch build.', + metavar='api_hash' + ) + buildParser.add_argument( + '--watchSigningIdentity', + required=False, + type=str, + help='Codesigning identity (SHA1 hash) for the watch app.', + metavar='identity' + ) + buildParser.add_argument( + '--watchProvisioningProfile', + required=False, + type=str, + help='Absolute path to the watchkitapp .mobileprovision file.', + metavar='path' + ) remote_build_parser = subparsers.add_parser('remote-build', help='Build the app using a remote environment.') add_codesigning_common_arguments(remote_build_parser)