diff --git a/CLAUDE.md b/CLAUDE.md index c564dc71b3..852c0a2b67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,7 +43,7 @@ A standalone watchOS Telegram client (developed in the separate `~/build/tgwatch **`Telegram/WatchApp/` is a synced snapshot — do not hand-edit it.** The source of truth and dev tooling live in the `tgwatch` repo. To change the watch app, edit it there, then re-sync with `tgwatch/tools/export-sources.sh /abs/path/to/telegram-ios/Telegram/WatchApp` and commit the result. The committed `tgwatch.xcodeproj` is generated (kept via a `!tgwatch.xcodeproj` negation in `Telegram/WatchApp/.gitignore`, since the root `.gitignore` ignores `*.xcodeproj`); `.build`/`.swiftpm`/`xcuserdata` are excluded. -**How it's wired:** `//Telegram:TelegramWatchApp` (rule in `Telegram/prebuilt_watchos.bzl`, worker `Telegram/prebuilt_watchos_build.sh`) runs xcodebuild on the snapshot in a writable temp copy, codesigns the `.app` + nested `TDLibFramework.framework` (identity + `ph.telegra.Telegraph.watchkitapp` profile from `--define`s), and feeds it to the `Telegram` `ios_application`'s `watch_application` slot (gated by the `//Telegram:embedWatchApp` flag). The snapshot is a tracked Bazel input, so the watch build re-runs only when it changes. +**How it's wired:** `//Telegram:TelegramWatchApp` (rule in `Telegram/prebuilt_watchos.bzl`) runs in **two actions**: `PrebuiltWatchosCompile` (`Telegram/prebuilt_watchos_compile.sh`) runs xcodebuild on the snapshot in a writable temp copy with PLACEHOLDER version/api values, emitting an unsigned `.app`; `PrebuiltWatchosPatchSign` (`Telegram/prebuilt_watchos_patch.sh`) then rewrites the four per-build Info.plist keys (`CFBundleShortVersionString`, `CFBundleVersion`, `TG_API_ID`, `TG_API_HASH`) and codesigns the `.app` + nested `TDLibFramework.framework` (identity + `ph.telegra.Telegraph.watchkitapp` profile from `--define`s). The result feeds the `Telegram` `ios_application`'s `watch_application` slot (gated by the `//Telegram:embedWatchApp` flag). **The compile action's only inputs are the snapshot (+ its worker)** — so changing the version, build number, api id/hash or signing identity re-runs only the cheap patch+sign action, not xcodebuild; xcodebuild re-runs only when the snapshot changes. This is correct because none of those four values reach the compiled binary: each lands only in the Info.plist (via `$(...)` substitution and a runtime `Bundle.main.object(forInfoDictionaryKey:)` lookup in `Secrets.swift`). **Non-obvious invariants** (also in the `.bzl` comments): `AppleBundleInfo`'s public init is banned — use the internal `new_applebundleinfo`; `watch_application` requires BOTH `AppleBundleInfo` (with a non-None `infoplist` File) AND `WatchosApplicationBundleInfo`; the embedded watch app's `CFBundleShortVersionString`/`CFBundleVersion` must exactly equal the host's (sourced from `versions.json['app']` + `--define=buildNumber`); the host does NOT re-sign the embedded watch app, so the worker must sign it; the watch bundle id `ph.telegra.Telegraph.watchkitapp` must track the host `telegram_bundle_id`. diff --git a/Telegram/prebuilt_watchos.bzl b/Telegram/prebuilt_watchos.bzl index f4ce9d66ad..a72b955dd5 100644 --- a/Telegram/prebuilt_watchos.bzl +++ b/Telegram/prebuilt_watchos.bzl @@ -1,9 +1,21 @@ """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 +`apple_prebuilt_watchos_application` builds the watch app in two actions and exposes it through the providers that `ios_application(watch_application = ...)` consumes: + 1. PrebuiltWatchosCompile (prebuilt_watchos_compile.sh) — runs `xcodebuild` against + the exported tgwatch source tree with PLACEHOLDER version/api values, emitting an + unsigned .app archive. Depends only on the source snapshot. + 2. PrebuiltWatchosPatchSign (prebuilt_watchos_patch.sh) — rewrites the four per-build + Info.plist keys (version, build number, api id/hash) on the compiled app, then + optionally codesigns it. + +Splitting them lets Bazel cache the (expensive, ~4-min) compile whenever only the +version/build number/api/identity change — those values never reach the compiled +binary, only the Info.plist. + +The providers exposed: + * AppleBundleInfo — bundle metadata (the host reads only `.product_type`). * AppleEmbeddableInfo — `watch_bundles` (the zipped .app placed under Watch/). @@ -43,13 +55,14 @@ def _apple_prebuilt_watchos_application_impl(ctx): # 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") + # Intermediate output of the compile action: the unsigned, placeholder-version .app. + # Keyed only on the source snapshot, so version/build/api/identity changes reuse it. + compiled_archive = ctx.actions.declare_file(ctx.label.name + "_compiled.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") - # Track the in-repo snapshot so the watch build re-runs only when it changes. - inputs = [ctx.file._worker, ctx.file.versions_json] + ctx.files.srcs exec_requirements = { "no-sandbox": "1", "no-remote": "1", @@ -57,11 +70,32 @@ def _apple_prebuilt_watchos_application_impl(ctx): "requires-network": "1", } + # Action 1 — compile. Inputs are ONLY the in-repo snapshot (+ the worker), so this + # (expensive) xcodebuild re-runs only when the watch sources change, not when the + # version, build number, api id/hash or signing identity change. ctx.actions.run( executable = "/bin/bash", arguments = [ - ctx.file._worker.path, + ctx.file._compile_worker.path, source_path, + compiled_archive.path, + ], + inputs = [ctx.file._compile_worker] + ctx.files.srcs, + outputs = [compiled_archive], + mnemonic = "PrebuiltWatchosCompile", + progress_message = "Compiling watch app via xcodebuild", + execution_requirements = exec_requirements, + use_default_shell_env = True, + ) + + # Action 2 — patch Info.plist (version/build/api) + optionally sign. Cheap; re-runs + # on any of those changes without re-running the compile above. versions.json (the + # marketing version) is an input here only, so a version bump skips the compile. + ctx.actions.run( + executable = "/bin/bash", + arguments = [ + ctx.file._patch_worker.path, + compiled_archive.path, archive.path, api_id, api_hash, @@ -71,10 +105,10 @@ def _apple_prebuilt_watchos_application_impl(ctx): ctx.file.versions_json.path, build_number, ], - inputs = inputs, + inputs = [ctx.file._patch_worker, compiled_archive, ctx.file.versions_json], outputs = [archive, infoplist], - mnemonic = "PrebuiltWatchosBuild", - progress_message = "Building%s watch app via xcodebuild" % (" + signing" if profile else ""), + mnemonic = "PrebuiltWatchosPatchSign", + progress_message = "Patching%s watch app Info.plist" % (" + signing" if profile else ""), execution_requirements = exec_requirements, use_default_shell_env = True, ) @@ -130,8 +164,12 @@ apple_prebuilt_watchos_application = rule( 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", + "_compile_worker": attr.label( + default = "//Telegram:prebuilt_watchos_compile.sh", + allow_single_file = True, + ), + "_patch_worker": attr.label( + default = "//Telegram:prebuilt_watchos_patch.sh", allow_single_file = True, ), }, diff --git a/Telegram/prebuilt_watchos_compile.sh b/Telegram/prebuilt_watchos_compile.sh new file mode 100755 index 0000000000..d85a089ade --- /dev/null +++ b/Telegram/prebuilt_watchos_compile.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Compile worker for the apple_prebuilt_watchos_application Bazel rule (action 1 of 2). +# +# Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED) with +# PLACEHOLDER version/api values, then zips the .app into the rule's intermediate +# archive. It deliberately depends only on the watch source snapshot — version, +# build number, api id/hash and signing are all applied later by +# prebuilt_watchos_patch.sh, so this (expensive, ~4-min) action stays cached across +# version/build/identity changes. +# +# Args: +# $1 source_path Execroot-relative path to the committed in-repo snapshot +# (Telegram/WatchApp), which contains tgwatch.xcodeproj. +# $2 output_zip Path (declared by Bazel) to write the unsigned .app archive to. +set -euo pipefail + +SRC="$1"; OUT_ZIP="$2" + +if [ ! -e "$SRC/tgwatch.xcodeproj" ]; then + echo "error: no tgwatch.xcodeproj at $SRC (re-sync the Telegram/WatchApp snapshot via tgwatch/tools/export-sources.sh)" >&2 + exit 1 +fi + +DD="$(mktemp -d)" +trap 'rm -rf "$DD"' EXIT + +# Build from a writable copy so xcodebuild/SwiftPM never write into the (possibly +# in-repo, read-only) source tree — e.g. SwiftPM's Package.resolved or the workspace. +# The tree is small (~12M); a plain cp on each (uncached) build is acceptable. +WORKSRC="$DD/src" +mkdir -p "$WORKSRC" +cp -R "$SRC/." "$WORKSRC/" + +# Version/api are placeholders here; prebuilt_watchos_patch.sh overwrites the four +# Info.plist keys afterward. They only ever land in the Info.plist (via $(...) +# substitution and a runtime Bundle.main lookup), never in the compiled binary, so +# the build output is independent of them. +xcodebuild \ + -project "$WORKSRC/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=0 TG_API_HASH=placeholder \ + MARKETING_VERSION=0.0 CURRENT_PROJECT_VERSION=0 \ + 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 + +# $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/Telegram/prebuilt_watchos_build.sh b/Telegram/prebuilt_watchos_patch.sh old mode 100644 new mode 100755 similarity index 57% rename from Telegram/prebuilt_watchos_build.sh rename to Telegram/prebuilt_watchos_patch.sh index 1e1a4d537b..25a333768c --- a/Telegram/prebuilt_watchos_build.sh +++ b/Telegram/prebuilt_watchos_patch.sh @@ -1,30 +1,33 @@ #!/usr/bin/env bash -# Worker for the apple_prebuilt_watchos_application Bazel rule. +# Patch + sign worker for the apple_prebuilt_watchos_application Bazel rule (action 2 of 2). # -# Builds the tgwatch watch app via xcodebuild (device, Release, UNSIGNED), then -# — if a provisioning profile is supplied — codesigns the app and its nested -# frameworks with the watchkitapp provisioning profile and a matching identity, -# and finally zips the .app into the rule's output archive. +# Takes the unsigned, placeholder-version watch .app archive produced by +# prebuilt_watchos_compile.sh, rewrites the four per-build Info.plist values +# (CFBundleShortVersionString, CFBundleVersion, TG_API_ID, TG_API_HASH) — none of +# which affect the compiled binary — then — if a provisioning profile is supplied — +# codesigns the app and its nested frameworks with the watchkitapp provisioning +# profile and a matching identity, and finally zips the .app into the rule's output +# archive. +# +# Splitting this from the compile step lets Bazel cache the (expensive) xcodebuild +# whenever only the version/build number/api/identity change. # # 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 Execroot-relative path to the committed in-repo snapshot -# (Telegram/WatchApp), which 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 => derived from $6's cert -# $6 profile Path to the watchkitapp .mobileprovision; empty => unsigned build +# $1 input_zip Compiled (unsigned, placeholder-version) .app archive from action 1 +# $2 output_zip Path (declared by Bazel) to write the final .app archive to +# $3 api_id TG_API_ID Info.plist value +# $4 api_hash TG_API_HASH Info.plist value +# $5 identity Codesigning identity (SHA1 hash); empty => derived from $6's cert +# $6 profile Path to the watchkitapp .mobileprovision; empty => unsigned build +# $7 infoplist_out Path (declared by Bazel) to copy the patched Info.plist to +# $8 versions_json versions.json (key 'app' => CFBundleShortVersionString) +# $9 build_number CFBundleVersion 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 $SRC (re-sync the Telegram/WatchApp snapshot via tgwatch/tools/export-sources.sh)" >&2 - exit 1 -fi +IN_ZIP="$1"; OUT_ZIP="$2"; API_ID="$3"; API_HASH="$4"; IDENTITY="${5:-}"; PROFILE="${6:-}"; INFOPLIST_OUT="${7:-}"; VERSIONS_JSON="${8:-}"; BUILD_NUMBER="${9:-1}" # Match the host app's version (rules_apple requires the embedded watch app's # CFBundleShortVersionString/CFBundleVersion to equal the parent's). @@ -36,35 +39,28 @@ fi DD="$(mktemp -d)" trap 'rm -rf "$DD"' EXIT -# Build from a writable copy so xcodebuild/SwiftPM never write into the (possibly -# in-repo, read-only) source tree — e.g. SwiftPM's Package.resolved or the workspace. -# The tree is small (~12M); a plain cp on each (uncached) build is acceptable. -WORKSRC="$DD/src" -mkdir -p "$WORKSRC" -cp -R "$SRC/." "$WORKSRC/" - -xcodebuild \ - -project "$WORKSRC/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)" +/usr/bin/ditto -x -k "$IN_ZIP" "$DD" +APP="$(find "$DD" -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 + echo "error: compiled watch .app not found inside $IN_ZIP" >&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. +# Overwrite the placeholder values baked in at compile time. All four keys already +# exist in the compiled (binary-format) Info.plist, so PlistBuddy Set preserves their +# (string) type — matching what $(...) substitution produced and what Secrets.swift +# expects from Bundle.main.object(forInfoDictionaryKey:). +PLIST="$APP/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $MARKETING_VERSION" "$PLIST" +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST" +/usr/libexec/PlistBuddy -c "Set :TG_API_ID $API_ID" "$PLIST" +/usr/libexec/PlistBuddy -c "Set :TG_API_HASH $API_HASH" "$PLIST" + +# Expose the patched watch Info.plist (the host reads it to verify the companion +# bundle-id linkage and the child version). Codesigning does not alter Info.plist +# content, so capture it now. if [ -n "$INFOPLIST_OUT" ]; then - cp "$APP/Info.plist" "$INFOPLIST_OUT" + cp "$PLIST" "$INFOPLIST_OUT" fi if [ -n "$IDENTITY" ] && [ -z "$PROFILE" ]; then @@ -122,7 +118,7 @@ else 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. +# (that would resolve $OUT_ZIP against the temp 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"