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) <noreply@anthropic.com>
This commit is contained in:
parent
675252724f
commit
64c9e72fad
4 changed files with 318 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
127
Telegram/prebuilt_watchos.bzl
Normal file
127
Telegram/prebuilt_watchos.bzl
Normal file
|
|
@ -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=<abs path to exported tgwatch sources>")
|
||||
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,
|
||||
),
|
||||
},
|
||||
)
|
||||
90
Telegram/prebuilt_watchos_build.sh
Normal file
90
Telegram/prebuilt_watchos_build.sh
Normal file
|
|
@ -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=<abs path>)" >&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"
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue