From 93aff4b716a58c16c24de74a6ea05b817210e45c Mon Sep 17 00:00:00 2001 From: isaac <> Date: Wed, 27 May 2026 18:35:32 +0200 Subject: [PATCH 1/6] Document InstantPage task-list checkboxes in CLAUDE.md Add a section covering the first-class InstantPageListItem.checked field: where things live, the API flags.0/flags.1 transmission, tri-state Postbox/ FlatBuffers persistence, V1/V2 detection + V2 CheckNode hosting, and the markdown round-trip contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 983c108cb1..c564dc71b3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -197,6 +197,36 @@ markdown-send gate above. - **Block `$$` detection** (`markdownBlockFormulaReplacement`): single-line `$$…$$` requires an exact `$$` opener (not `$$$`) and trailing whitespace only; multi-line requires a **bare** `$$` opener line. `$$x$$ trailing text` falls through to the inline rule. The `\[…\]` opener path is unchanged and exempt from these `$$`-only guards. - **Detection is shared with the document path; the gate is chat-only.** `markdownPreparedSource` (detection) runs for both chat and document attachments. The triggers (`richTextIsEntityExpressible`/`blockIsEntityExpressible` → `.formula` is non-expressible; `$`/`\(`/`\[` in `markdownMightNeedRichLayout`) are read only by the chat classifier `richMarkdownAttributeIfNeeded`. +## InstantPageListItem task-list checkboxes (`- [ ]` / `- [x]`) + +`InstantPageListItem` carries a first-class `checked: Bool?` — the **third** associated value of `.text(RichText, String?, Bool?)` / `.blocks([InstantPageBlock], String?, Bool?)`, orthogonal to the ordered-list `num` — representing a GitHub-style task-list checkbox. `nil` = not a checkbox item, `false` = unchecked, `true` = checked. Covers markdown parse, Postbox + FlatBuffers serialization, Telegram API transmission, display (V1 + V2), the edit round-trip, and previews. + +Spec: [`docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md`](docs/superpowers/specs/2026-05-27-instantpage-list-checkbox-design.md). Plan: [`docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md`](docs/superpowers/plans/2026-05-27-instantpage-list-checkbox.md). + +### Where things live + +| File | Responsibility | +|---|---| +| `submodules/TelegramCore/Sources/SyncCore/SyncCore_InstantPage.swift` | The `checked: Bool?` enum payload; Postbox coding (key `"ck"`, tri-state Int32); `==`; FlatBuffers codec. Internal tri-state helpers `checkedFromTriState`/`triState(fromChecked:)`. | +| `submodules/TelegramCore/FlatSerialization/Models/InstantPageBlock.fbs` | `checkState:int32 (id: 2)` on `InstantPageListItem_Text` + `_Blocks`. **Source of truth**; the Bazel `flatc` genrule regenerates the Swift (checked-in `*_generated.swift` is stale). | +| `submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift` | `checked` / `num` accessors; reads & writes the API `checkbox`=flags.0 / `checked`=flags.1 bits via `checkedFromApiFlags` / `apiFlags(fromChecked:)` across all four list-item types. | +| `submodules/BrowserUI/Sources/BrowserMarkdown.swift` | Forward parse: `markdownTaskListMarker` detects `[ ]`/`[x]`/`[X]`; the result routes into `checked` (NOT `num`). | +| `submodules/BrowserUI/Sources/InstantPageToMarkdown.swift` | Reverse: emits `- [ ] ` / `- [x] ` from `item.checked` for the edit round-trip. | +| `submodules/InstantPageUI/Sources/InstantPageV2Layout.swift` | V2 detection via `item.checked`; `.checklist(checked:colors:)` marker carrying `InstantPageV2CheckboxColors`. | +| `submodules/InstantPageUI/Sources/InstantPageRenderer.swift` | V2 marker view (`InstantPageV2ListMarkerView`) hosts a real `CheckNode`. | +| `submodules/InstantPageUI/Sources/InstantPageLayout.swift` | V1 detection via `item.checked` (renders the existing `InstantPageChecklistMarkerItem`). | +| `submodules/TelegramStringFormatting/Sources/InstantPagePreviewText.swift` | `previewText()` renders a `☐`/`☑︎` glyph + body for checkbox items. | + +### Non-obvious invariants + +- **`checked` is orthogonal to `num`.** The API keeps `checkbox`/`checked` as flags **separate from the list number**, so an ordered item can be both numbered AND a checkbox. This is exactly why the first-class field replaced an earlier sentinel-string-in-`num` prototype (which could not represent both). No `\u{001f}tg-md-task:*` sentinel remains anywhere. +- **API bits are `checkbox`=flags.0, `checked`=flags.1 on ALL FOUR list-item constructors** (`pageListItemText`/`Blocks` and `pageListOrderedItemText`/`Blocks`, in and out — `pageListItemText#2f58683c`, `pageListOrderedItemText#cd3ea036`, etc.). The iOS `Api.*` layer exposes only `flags: Int32`; mask the bits (`apiFlags(fromChecked:)` / `checkedFromApiFlags`). Because state rides the flags (not the text), it survives the server round-trip for sender + recipients — **including the sender's own send-confirmation echo** (`applyUpdateMessage` replaces local attributes with the server's reconstruction, `ApplyUpdateMessage.swift`). +- **Tri-state persistence `0=nil, 1=unchecked, 2=checked`** in BOTH Postbox (key `"ck"`, decoded with `decodeInt32ForKey(orElse: 0)`) and FlatBuffers (`checkState:int32`, default 0). Absent/0 → `nil`, so pre-existing stored pages decode unchanged. +- **Detection reads `item.checked != nil`** in both layout engines (was `instantPageTaskListMarkerState(item.num)`); the V2 marker kind is `.checklist(checked: item.checked == true, colors:)`. The empty-blocks `.blocks → .text(.plain(" "), num, checked)` promotion must carry `checked` through, not drop it. +- **V2 `CheckNode` is hosted directly in a plain `UIView`**, not an ASDisplayNode tree, so `checkNode.displaysAsynchronously = false` is set to avoid a first-draw blank flash (the V2 pageView is rebuilt per streaming chunk — see the AI streaming section). `InstantPageV2CheckboxColors` (background←`panelAccentColor`, stroke←`pageBackgroundColor`, border←`controlColor`) is carried on the `.checklist` payload and mirrors the V1 `instantPageChecklistMarkerTheme`. +- **Forward parser keeps `[ ]` detection but routes to `checked`.** `markdownApplyTaskListMarker`/`markdownStrippingTaskListMarker`/`markdownTaskListMarker` still strip the marker from the item text; the state flows into `checked` while ordered items keep their real `"\(ordinal)"` number. The reverse converter emits lowercase `[x]` / `[ ]`, which the forward `hasPrefix` guards re-parse — that is the round-trip contract. +- **The enum-arity change is compile-enforced.** Adding the third associated value broke every `.text`/`.blocks` construction/destructure; the full build is the completeness gate. Read-only consumers outside the core set exist (`BrowserInstantPageContent.swift`, `CachedFaqInstantPage.swift`) — grep `\.(text|blocks)\(` repo-wide when touching the enum again. + ## Postbox → TelegramEngine refactor (in progress) A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`. From 21d5b4879b8da5157cbf5e80e2f60ce60858c0f0 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 14 May 2026 12:56:46 +0200 Subject: [PATCH 2/6] Various fixes --- .../Sources/SyncCore/SyncCore_TelegramMediaPoll.swift | 3 +-- .../EmojiGameStakeScreen/Sources/EmojiGameStakeScreen.swift | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift index 3038d37171..285d87b9eb 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaPoll.swift @@ -463,7 +463,7 @@ public final class TelegramMediaPoll: Media, Equatable { } } updatedResults = TelegramMediaPollResults(voters: updatedVoters.map({ voters in - return TelegramMediaPollOptionVoters(selected: selectedOpaqueIdentifiers.contains(voters.opaqueIdentifier), opaqueIdentifier: voters.opaqueIdentifier, count: voters.count, isCorrect: correctOpaqueIdentifiers.contains(voters.opaqueIdentifier), recentVoters: voters.recentVoters) + return TelegramMediaPollOptionVoters(selected: selectedOpaqueIdentifiers.contains(voters.opaqueIdentifier), opaqueIdentifier: voters.opaqueIdentifier, count: voters.count, isCorrect: voters.isCorrect || correctOpaqueIdentifiers.contains(voters.opaqueIdentifier), recentVoters: voters.recentVoters) }), totalVoters: results.totalVoters, recentVoters: results.recentVoters, solution: results.solution ?? self.results.solution, hasUnseenVotes: results.hasUnseenVotes ?? self.results.hasUnseenVotes, canViewStats: results.canViewStats) } else if let updatedVoters = results.voters { updatedResults = TelegramMediaPollResults(voters: updatedVoters, totalVoters: results.totalVoters, recentVoters: results.recentVoters, solution: results.solution ?? self.results.solution, hasUnseenVotes: results.hasUnseenVotes ?? self.results.hasUnseenVotes, canViewStats: results.canViewStats) @@ -481,4 +481,3 @@ public final class TelegramMediaPoll: Media, Equatable { return TelegramMediaPoll(pollId: self.pollId, publicity: self.publicity, kind: self.kind, text: self.text, textEntities: self.textEntities, options: self.options, correctAnswers: self.correctAnswers, results: updatedResults, isClosed: self.isClosed, deadlineTimeout: self.deadlineTimeout, deadlineDate: self.deadlineDate, pollHash: self.pollHash, openAnswers: self.openAnswers, revotingDisabled: self.revotingDisabled, shuffleAnswers: self.shuffleAnswers, hideResultsUntilClose: self.hideResultsUntilClose, isCreator: self.isCreator, attachedMedia: self.attachedMedia, restrictToSubscribers: self.restrictToSubscribers, countries: self.countries) } } - diff --git a/submodules/TelegramUI/Components/EmojiGameStakeScreen/Sources/EmojiGameStakeScreen.swift b/submodules/TelegramUI/Components/EmojiGameStakeScreen/Sources/EmojiGameStakeScreen.swift index ad65c83f95..6cd8c4fd58 100644 --- a/submodules/TelegramUI/Components/EmojiGameStakeScreen/Sources/EmojiGameStakeScreen.swift +++ b/submodules/TelegramUI/Components/EmojiGameStakeScreen/Sources/EmojiGameStakeScreen.swift @@ -1008,7 +1008,7 @@ public final class AmountFieldComponent: Component { case .stars: text = "\(value)" case .ton: - text = "\(formatTonAmountText(value, dateTimeFormat: PresentationDateTimeFormat(timeFormat: component.dateTimeFormat.timeFormat, dateFormat: component.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: ".", groupingSeparator: ""), maxDecimalPositions: nil))" + text = "\(formatTonAmountText(value, dateTimeFormat: PresentationDateTimeFormat(timeFormat: component.dateTimeFormat.timeFormat, dateFormat: component.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: component.dateTimeFormat.decimalSeparator, groupingSeparator: component.dateTimeFormat.groupingSeparator), maxDecimalPositions: nil))" } self.textField.text = text self.placeholderView.view?.isHidden = !(self.textField.text ?? "").isEmpty @@ -1028,7 +1028,7 @@ public final class AmountFieldComponent: Component { case .stars: text = "\(value)" case .ton: - text = "\(formatTonAmountText(value, dateTimeFormat: PresentationDateTimeFormat(timeFormat: component.dateTimeFormat.timeFormat, dateFormat: component.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: ".", groupingSeparator: ""), maxDecimalPositions: nil))" + text = "\(formatTonAmountText(value, dateTimeFormat: PresentationDateTimeFormat(timeFormat: component.dateTimeFormat.timeFormat, dateFormat: component.dateTimeFormat.dateFormat, dateSeparator: "", dateSuffix: "", requiresFullYear: false, decimalSeparator: component.dateTimeFormat.decimalSeparator, groupingSeparator: component.dateTimeFormat.groupingSeparator), maxDecimalPositions: nil))" } self.textField.text = text self.placeholderView.view?.isHidden = !text.isEmpty From c9a9f8a0609b35fd0784344dd9158c66534f6c0f Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Thu, 14 May 2026 19:06:03 +0200 Subject: [PATCH 3/6] Various fixes --- .../NotificationService/Sources/NotificationService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index d52e425248..66f39d8a5b 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -1286,9 +1286,9 @@ private final class NotificationServiceHandler { action = .pollStories(peerId: peerId, content: content, storyId: storyId, isReaction: isReaction) } else { var reportDelivery = false - if let reportDeliveryUntilDate = aps["report_delivery_until_date"] as? Int32 { + if let reportDeliveryUntilDate = aps["report_delivery_until_date"] as? String, let reportDeliveryUntilDateValue = Int32(reportDeliveryUntilDate) { let currentTime = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - if reportDeliveryUntilDate > currentTime { + if reportDeliveryUntilDateValue > currentTime { reportDelivery = true } } From c3ab3ff2180aa30a1d51c87e489d77033b41f486 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 28 May 2026 02:12:02 +0200 Subject: [PATCH 4/6] Animate InstantPage V2 details (collapsible) expand/collapse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread the chat list's animated transition through InstantPageV2View.update so reused-item frame changes animate: the toggled details view's own frame-height (clipsToBounds) clip-reveals/clip-hides its body, sibling items below slide, and the chevron rotates — all on the one transition the bubble already drives for its outer height. On expand the body is added up-front; on collapse it is kept and torn down in finalizePendingCollapse(), called from the completion of the frame-shrink (clip) animation that hides it. Drop the forced scroll-to-center on toggle so the bubble grows in place. Also render the chevron as a UIImageView with a template image + tintColor (the message's primary text color) instead of baking the tint into a CALayer's cgImage contents, which dropped the color and rendered it black. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/InstantPageRenderer.swift | 142 ++++++++++++------ ...ChatMessageRichDataBubbleContentNode.swift | 2 +- 2 files changed, 95 insertions(+), 49 deletions(-) diff --git a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift index cc8507ea8c..f98b2e6574 100644 --- a/submodules/InstantPageUI/Sources/InstantPageRenderer.swift +++ b/submodules/InstantPageUI/Sources/InstantPageRenderer.swift @@ -180,8 +180,6 @@ public final class InstantPageV2View: UIView { theme: InstantPageTheme, animation: ListViewItemUpdateAnimation ) { - let _ = animation // reserved for future per-item animation - // Build map of existing views by stable id. var oldViewsById: [InstantPageV2StableItemId: InstantPageItemView] = [:] for (oldIndex, oldId) in self.itemViewStableIds.enumerated() { @@ -195,8 +193,19 @@ public final class InstantPageV2View: UIView { for (position, item) in layout.items.enumerated() { let id = InstantPageV2View.stableId(for: item, atPosition: position) - if let existing = oldViewsById[id], let reusedView = self.reuse(existingView: existing, for: item, theme: theme) { - reusedView.frame = InstantPageV2View.actualFrame(forItem: item) // parent positions child + if let existing = oldViewsById[id], let reusedView = self.reuse(existingView: existing, for: item, theme: theme, animation: animation) { + let newFrame = InstantPageV2View.actualFrame(forItem: item) // parent positions child + if animation.isAnimated && reusedView.frame != newFrame { + // A collapsing details view keeps its body alive; remove it once this + // frame-shrink (the clip that hides it) finishes — see finalizePendingCollapse(). + let detailsView = reusedView as? InstantPageV2DetailsView + animation.animator.updateFrame(layer: reusedView.layer, frame: newFrame, completion: { [weak detailsView] _ in + detailsView?.finalizePendingCollapse() + }) + } else { + reusedView.frame = newFrame + (reusedView as? InstantPageV2DetailsView)?.finalizePendingCollapse() + } newItemViews.append(reusedView) newStableIds.append(id) reusedIds.insert(id) @@ -425,7 +434,7 @@ public final class InstantPageV2View: UIView { /// Returns the input view typed-updated against `item`, or `nil` if the existing view's /// concrete class doesn't match the item's case (e.g. a `text` slot has been replaced by /// a `divider` in the new layout). Caller falls back to `makeItemView`. - private func reuse(existingView: InstantPageItemView, for item: InstantPageV2LaidOutItem, theme: InstantPageTheme) -> InstantPageItemView? { + private func reuse(existingView: InstantPageItemView, for item: InstantPageV2LaidOutItem, theme: InstantPageTheme, animation: ListViewItemUpdateAnimation) -> InstantPageItemView? { switch item { case let .text(text): guard let v = existingView as? InstantPageV2TextView else { return nil } @@ -457,7 +466,7 @@ public final class InstantPageV2View: UIView { return v case let .details(details): guard let v = existingView as? InstantPageV2DetailsView else { return nil } - v.update(item: details, theme: theme, renderContext: self.renderContext) + v.update(item: details, theme: theme, renderContext: self.renderContext, animation: animation) return v case let .table(table): guard let v = existingView as? InstantPageV2TableView else { return nil } @@ -1468,11 +1477,18 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { var itemFrame: CGRect { return self.item.frame } let titleTextView: InstantPageV2TextView - private let chevronLayer: CALayer + private let chevronView: UIImageView private let separator: UIView var bodyView: InstantPageV2View? private let titleHitView: UIView + // The expanded chevron is the collapsed one rotated 180° (down → up). + private static let expandedChevronTransform = CATransform3DMakeRotation(CGFloat.pi, 0.0, 0.0, 1.0) + // On an animated collapse the body is kept until the toggle animation finishes (so the + // shrinking clip can hide it), then removed in finalizePendingCollapse() — which the parent + // (InstantPageV2View.update) calls from the completion of the frame-shrink (clip) animation. + private var bodyPendingRemoval = false + var onTitleTapped: ((Int) -> Void)? var subLayoutView: InstantPageV2View? { return self.bodyView } @@ -1491,13 +1507,16 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { self.titleTextView = InstantPageV2TextView(item: titleV2Item) self.titleTextView.isUserInteractionEnabled = false - self.chevronLayer = CALayer() - // V1 uses a custom-drawn InstantPageDetailsArrowNode; V2 uses a SF Symbol for simplicity. - // (SF Symbol "chevron.up/down" is iOS 13+ which matches our minimum deployment target.) - let chevronImage = UIImage(systemName: item.isExpanded ? "chevron.up" : "chevron.down")? - .withTintColor(theme.textCategories.paragraph.color, renderingMode: .alwaysOriginal) - self.chevronLayer.contents = chevronImage?.cgImage - self.chevronLayer.contentsGravity = .resizeAspect + self.chevronView = UIImageView() + // Single downward chevron; the expanded state is a 180° rotation (animatable) rather than + // an instant chevron.up/chevron.down image swap. A template image + tintColor renders the + // SF Symbol in the message's primary text color — baking the color into a CALayer's cgImage + // contents drops the tint and renders black. (SF Symbol is iOS 13+.) + self.chevronView.image = UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate) + self.chevronView.tintColor = theme.textCategories.paragraph.color + self.chevronView.contentMode = .scaleAspectFit + // Decorative: let taps fall through to titleHitView (which carries the toggle gesture). + self.chevronView.isUserInteractionEnabled = false self.separator = UIView() self.separator.backgroundColor = item.separatorColor @@ -1511,16 +1530,18 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { self.clipsToBounds = true self.addSubview(self.titleTextView) - self.layer.addSublayer(self.chevronLayer) + self.addSubview(self.chevronView) self.addSubview(self.separator) let chevronSize = CGSize(width: 18.0, height: 18.0) - self.chevronLayer.frame = CGRect( - x: item.titleFrame.maxX - chevronSize.width - 12.0, - y: item.titleFrame.midY - chevronSize.height / 2.0, - width: chevronSize.width, - height: chevronSize.height + // bounds + center (not frame) so the rotation transform pivots around the center and the + // frame stays well-defined while a non-identity transform is applied. + self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize) + self.chevronView.center = CGPoint( + x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0, + y: item.titleFrame.midY ) + self.chevronView.layer.transform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity // V1 (InstantPageDetailsNode.swift:138): separator sits at titleHeight - UIScreenPixel. self.separator.frame = CGRect( @@ -1553,8 +1574,7 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { self.onTitleTapped?(self.item.index) } - func update(item: InstantPageV2DetailsItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext?) { - let previousIsExpanded = self.item.isExpanded + func update(item: InstantPageV2DetailsItem, theme: InstantPageTheme, renderContext: InstantPageV2RenderContext?, animation: ListViewItemUpdateAnimation) { self.item = item let titleV2Item = InstantPageV2TextItem( @@ -1563,15 +1583,12 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { ) self.titleTextView.update(item: titleV2Item, theme: theme) - let chevronImage = UIImage(systemName: item.isExpanded ? "chevron.up" : "chevron.down")? - .withTintColor(theme.textCategories.paragraph.color, renderingMode: .alwaysOriginal) - self.chevronLayer.contents = chevronImage?.cgImage + self.chevronView.tintColor = theme.textCategories.paragraph.color let chevronSize = CGSize(width: 18.0, height: 18.0) - self.chevronLayer.frame = CGRect( - x: item.titleFrame.maxX - chevronSize.width - 12.0, - y: item.titleFrame.midY - chevronSize.height / 2.0, - width: chevronSize.width, - height: chevronSize.height + self.chevronView.bounds = CGRect(origin: .zero, size: chevronSize) + self.chevronView.center = CGPoint( + x: item.titleFrame.maxX - chevronSize.width / 2.0 - 12.0, + y: item.titleFrame.midY ) self.separator.backgroundColor = item.separatorColor @@ -1584,29 +1601,58 @@ final class InstantPageV2DetailsView: UIView, InstantPageItemView { self.titleHitView.frame = item.titleFrame - // Body recursion: if both old and new are expanded with a body, forward the update. - // If the expand state changed, tear down and rebuild (task B refines). - if previousIsExpanded && item.isExpanded, let innerLayout = item.innerLayout, let existingBody = self.bodyView { - existingBody.update(layout: innerLayout, theme: theme, animation: .None) - existingBody.frame = CGRect( - origin: CGPoint(x: 0.0, y: item.titleFrame.maxY), - size: innerLayout.contentSize - ) - } else { - if let existingBody = self.bodyView { - existingBody.removeFromSuperview() - self.bodyView = nil - } - if item.isExpanded, let innerLayout = item.innerLayout { - let body = InstantPageV2View(renderContext: renderContext) - body.update(layout: innerLayout, theme: theme, animation: .None) + // Body lifecycle. The reveal/hide of the body is produced by the parent animating this + // view's own frame height (clipsToBounds = true), not by the body itself — see + // InstantPageV2View.update. The body's internal layout is forwarded `animation` so a + // *nested* details block inside the body can also animate its own toggle. + if item.isExpanded { + if let innerLayout = item.innerLayout { + let body: InstantPageV2View + if let existingBody = self.bodyView { + // Reuse: covers expanded→expanded content updates and a re-expand that + // interrupts a still-pending collapse removal. + body = existingBody + self.bodyPendingRemoval = false + } else { + body = InstantPageV2View(renderContext: renderContext) + self.addSubview(body) + self.bodyView = body + } + body.update(layout: innerLayout, theme: theme, animation: animation) body.frame = CGRect( origin: CGPoint(x: 0.0, y: item.titleFrame.maxY), size: innerLayout.contentSize ) - self.addSubview(body) - self.bodyView = body } + } else { + if let existingBody = self.bodyView { + if animation.isAnimated { + // Keep the body visible while the parent's frame-shrink clips it away; + // it is removed in finalizePendingCollapse() (called from that clip animation's + // completion in InstantPageV2View.update). + self.bodyPendingRemoval = true + } else { + existingBody.removeFromSuperview() + self.bodyView = nil + } + } + } + + // Chevron rotation. The body teardown on collapse is NOT tied to this completion — see + // finalizePendingCollapse(), which the parent calls from the frame-shrink (clip) animation. + let targetTransform = item.isExpanded ? InstantPageV2DetailsView.expandedChevronTransform : CATransform3DIdentity + animation.animator.updateTransform(layer: self.chevronView.layer, transform: targetTransform, completion: nil) + } + + /// Removes the body kept alive across an animated collapse. The parent (InstantPageV2View.update) + /// calls this from the completion of the frame-shrink animation that clips the body away, so the + /// body is torn down exactly when it finishes being hidden. The guard makes a re-expand that + /// interrupts the collapse safe — the re-expand clears `bodyPendingRemoval` first. + func finalizePendingCollapse() { + if !self.item.isExpanded, self.bodyPendingRemoval { + self.bodyView?.removeFromSuperview() + self.bodyView = nil + self.bodyPendingRemoval = false } } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift index e217ec835e..d4dc6c9ec1 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageRichDataBubbleContentNode/Sources/ChatMessageRichDataBubbleContentNode.swift @@ -148,7 +148,7 @@ public class ChatMessageRichDataBubbleContentNode: ChatMessageBubbleContentNode let current = self.currentExpandedDetails[index] ?? self.defaultExpanded(forDetailsIndex: index) self.currentExpandedDetails[index] = !current if let item = self.item { - item.controllerInteraction.requestMessageUpdate(item.message.id, true, nil) + item.controllerInteraction.requestMessageUpdate(item.message.id, false, nil) } } return view From 0be476ff790643a543be46a9080c19bfc4b31021 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 28 May 2026 02:13:30 +0200 Subject: [PATCH 5/6] Strip watch app --- Telegram/WatchApp/project.yml | 11 +++++++++++ Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj | 3 +++ 2 files changed, 14 insertions(+) diff --git a/Telegram/WatchApp/project.yml b/Telegram/WatchApp/project.yml index ebc0099a2f..44a381228f 100644 --- a/Telegram/WatchApp/project.yml +++ b/Telegram/WatchApp/project.yml @@ -79,6 +79,17 @@ targets: CODE_SIGNING_ALLOWED: "NO" CODE_SIGNING_REQUIRED: "NO" CODE_SIGN_IDENTITY: "" + configs: + Release: + # Bazel consumes a plain `xcodebuild build`, so DEPLOYMENT_POSTPROCESSING + # stays NO and the default strip phase never runs — the executable ships + # with its full ~1M-symbol table, doubling each thinned slice to ~100MB + # and tripping App Store's 75MB watchOS limit (error 90389). Force the + # strip during build. Scoped to this target only: a project-wide setting + # also tries to strip the SPM static libs, which errors on COgg.o. + DEPLOYMENT_POSTPROCESSING: "YES" + STRIP_INSTALLED_PRODUCT: "YES" + STRIP_STYLE: "all" dependencies: - package: TDLibKit - package: QRCodeGenerator diff --git a/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj b/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj index dd7e37625c..ab950ef6f3 100644 --- a/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj +++ b/Telegram/WatchApp/tgwatch.xcodeproj/project.pbxproj @@ -641,6 +641,7 @@ CODE_SIGNING_ALLOWED = NO; CODE_SIGNING_REQUIRED = NO; CODE_SIGN_IDENTITY = ""; + DEPLOYMENT_POSTPROCESSING = YES; DEVELOPMENT_TEAM = C67CF9S4VU; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = "tgwatch Watch App/Info.plist"; @@ -648,6 +649,8 @@ PRODUCT_BUNDLE_IDENTIFIER = ph.telegra.Telegraph.watchkitapp; SDKROOT = watchos; SKIP_INSTALL = YES; + STRIP_INSTALLED_PRODUCT = YES; + STRIP_STYLE = all; TARGETED_DEVICE_FAMILY = 4; WATCHOS_DEPLOYMENT_TARGET = 26.0; }; From ddf6999908dabf720cc85f08c12d9c568c816491 Mon Sep 17 00:00:00 2001 From: isaac <> Date: Thu, 28 May 2026 02:45:47 +0200 Subject: [PATCH 6/6] Optimize watch building --- CLAUDE.md | 2 +- Telegram/prebuilt_watchos.bzl | 58 ++++++++++--- Telegram/prebuilt_watchos_compile.sh | 60 +++++++++++++ ...hos_build.sh => prebuilt_watchos_patch.sh} | 84 +++++++++---------- 4 files changed, 149 insertions(+), 55 deletions(-) create mode 100755 Telegram/prebuilt_watchos_compile.sh rename Telegram/{prebuilt_watchos_build.sh => prebuilt_watchos_patch.sh} (57%) mode change 100644 => 100755 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"