Consumer-sweep, facade-addition, and Peer→EnginePeer migrations: - Wave 27: preferencesView consumer sweep - Wave 28: resourceData consumer sweep - Wave 29: resourceStatus consumer sweep - Wave 30: _asStatus() bridge cleanup - Wave 31: unused-import sweep re-run - Wave 32: resourceStatus residue sweep - Wave 33: loadedPeerWithId consumer sweep - Wave 34: FoundPeer.peer Peer -> EnginePeer - Wave 35: SendAsPeer.peer Peer -> EnginePeer - Wave 36: ContactListPeer.peer Peer -> EnginePeer Also includes per-wave specs, implementation plans, outcome logs, and a CLAUDE.md wave-counter update. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
18 KiB
Wave 34 Design: FoundPeer.peer: Peer → EnginePeer
Date: 2026-04-24
Wave: 34 (Postbox → TelegramEngine refactor)
Predecessor: Wave 33 (loadedPeerWithId consumer sweep, commit 16d017853a)
Goal
Migrate the public field FoundPeer.peer from the Postbox Peer protocol to the TelegramCore EnginePeer enum. Drops 4 of the 5 ._asPeer() bridges introduced by wave 33 and eliminates one Postbox-protocol leak from a TelegramEngine.Contacts / TelegramEngine.Calls return type.
Non-Goals
- Migrating other Peer-typed-API surfaces (
SendAsPeer,makePeerInfoController,makeChatRecentActionsController,makeChatQrCodeScreen,FoundPeeris the smallest probe in this class — those are separate future waves). - Dropping
import PostboxfromSearchPeers.swift. The_internal_*functions in that file still callpostbox.transaction,parseTelegramGroupOrChannel,AccumulatedPeers,updatePeers. They remain the Postbox-facing layer per project rule. - Dropping
import Postboxfrom any consumer module. None of the touched files reach zero Postbox use through this change alone. - Auto-synthesizing
EquatableforFoundPeer. Manual==is preserved per user decision.
Scope
One atomic commit. Approximately 46 semantic edits plus type-name continuations across:
submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift(definition +_internal_searchPeersbody)- 7 consumer files in
submodules/:submodules/TelegramCallsUI/Sources/VideoChatScreen.swiftsubmodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swiftsubmodules/ContactListUI/Sources/ContactListNode.swiftsubmodules/ChatListUI/Sources/ChatListSearchListPaneNode.swiftsubmodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreenCallActions.swiftsubmodules/TelegramBaseController/Sources/TelegramBaseController.swiftsubmodules/SettingsUI/Sources/Data and Storage/StorageUsageExceptionsScreen.swift
The remaining ~10 files identified by grep -rln "FoundPeer\b" (StorageUsageExceptionsScreen field-only refs aside, the file IS in the touched list above) contain only C5 type-name mentions or unrelated .peer.peer accesses on other types and require no edit.
Verification (performed 2026-04-24) that nearby EnginePeer(peer.peer) patterns in other files are NOT FoundPeer access: those sites bind peer to SelectivePrivacyPeer, SendAsPeer, InactiveChannel, RenderedChannelParticipant, or RenderedPeer — all of which still expose .peer: Peer. They remain unchanged by this wave.
Changes
1. submodules/TelegramCore/Sources/TelegramEngine/Peers/SearchPeers.swift
Struct:
public struct FoundPeer: Equatable {
public let peer: EnginePeer // was: Peer
public let subscribers: Int32?
public init(peer: EnginePeer, subscribers: Int32?) { // was: peer: Peer
self.peer = peer
self.subscribers = subscribers
}
public static func ==(lhs: FoundPeer, rhs: FoundPeer) -> Bool {
return lhs.peer == rhs.peer && lhs.subscribers == rhs.subscribers
// was: lhs.peer.isEqual(rhs.peer) && lhs.subscribers == rhs.subscribers
}
}
_internal_searchPeers body changes:
- All four
FoundPeer(peer: peer, subscribers: …)constructions (lines 70, 72, 85, 87) wrap the rawpeervalue withEnginePeer(peer). - Six scope-filter expressions (2 per non-trivial scope × 3 scopes —
.channelslines 96–109,.groupslines 110–128,.privateChatslines 129–143) rewrite to enum pattern matching:as? TelegramChannel, case .broadcast = channel.info→if case let .channel(channel) = item.peer, case .broadcast = channel.infoas? TelegramChannel, case .group = channel.infopluselse if item.peer is TelegramGroup→if case let .channel(channel) = item.peer, case .group = channel.infopluselse if case .legacyGroup = item.peerif item.peer is TelegramUser→if case .user = item.peer
Filter behavior is preserved exactly; only the destructuring form changes.
2. Consumer-side edits (by category)
Inventory was performed on 2026-04-24 via Explore agent against the 10 files identified by grep -rln "FoundPeer\b" submodules/ Telegram/. An additional 3 files surfaced (ShareControllerNode.swift, SharePeersContainerNode.swift, PeerSelectionControllerNode.swift, ContactSelectionControllerNode.swift, ChatListNode.swift) — most are C5 type-name mentions or false positives in field names that don't reference the type.
C1 — peer-protocol method reads (~28 sites): no edit required.
peer.peer.id, peer.peer.displayTitle, peer.peer.namespace, peer.peer.debugDisplayTitle, peer.peer.smallProfileImage — all available on EnginePeer with the same signatures.
C5 — type-signature mentions (~60 sites): no edit required.
[FoundPeer], Signal<([FoundPeer], [FoundPeer]), NoError>, Atomic<([FoundPeer], [FoundPeer])?>, case globalPeer(FoundPeer, …), etc. The type continues to compile under the new field.
C2 — downcast rewrites (30 sites).
EnginePeer is an enum, so peer.peer as? TelegramX / peer.peer is TelegramX patterns must rewrite to if case .X = peer.peer (or if case let .X(x) = peer.peer when the bound value is reused). Case mapping:
TelegramUser→.userTelegramSecretChat→.secretChatTelegramGroup→.legacyGroupTelegramChannel→.channel
| File | Line | Current pattern | After (representative) |
|---|---|---|---|
TelegramCallsUI/VideoChatScreenMoreMenu.swift |
628 | peer.peer is TelegramGroup |
if case .legacyGroup = peer.peer |
TelegramCallsUI/VideoChatScreenMoreMenu.swift |
631 | as? TelegramChannel, case .group = peer.info |
if case let .channel(channel) = peer.peer, case .group = channel.info |
TelegramCallsUI/VideoChatScreenMoreMenu.swift |
648 | as? TelegramChannel, case .broadcast = peer.info |
if case let .channel(channel) = peer.peer, case .broadcast = channel.info |
ContactListUI/ContactListNode.swift |
1501 | if let _ = peer.peer as? TelegramChannel |
if case .channel = peer.peer |
ContactListUI/ContactListNode.swift |
1563, 1569, 1574 | if let user = peer.peer as? TelegramUser, user.flags.contains(.requirePremium) |
if case let .user(user) = peer.peer, user.flags.contains(.requirePremium) |
ContactListUI/ContactListNode.swift |
1658, 1665, 1695, 1703, 1733 | let user = peer.peer as? TelegramUser (in if-let chains) |
if case let .user(user) = peer.peer, … |
ContactListUI/ContactListNode.swift |
1673, 1711 | if peer.peer is TelegramGroup (with possible && <bool>) |
if case .legacyGroup = peer.peer (with , <bool>) |
ContactListUI/ContactListNode.swift |
1675, 1713 | else if let channel = peer.peer as? TelegramChannel |
else if case let .channel(channel) = peer.peer |
ChatListUI/ChatListSearchListPaneNode.swift |
1024 | !(peer.peer is TelegramUser || peer.peer is TelegramSecretChat) |
rewrite to combined enum-pattern (×2 within the line) |
ChatListUI/ChatListSearchListPaneNode.swift |
1029, 1030 | if let _ = peer.peer as? TelegramGroup / else if let peer = peer.peer as? TelegramChannel, case .group = peer.info |
if case .legacyGroup = peer.peer / else if case let .channel(channel) = peer.peer, case .group = channel.info |
ChatListUI/ChatListSearchListPaneNode.swift |
1038, 1040 | if peer.peer is TelegramUser / else if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info |
if case .user = peer.peer / else if case let .channel(channel) = peer.peer, case .broadcast = channel.info |
ChatListUI/ChatListSearchListPaneNode.swift |
1500, 1507 | if let channel = peer.peer as? TelegramChannel, case .broadcast = channel.info |
if case let .channel(channel) = peer.peer, case .broadcast = channel.info |
PeerInfoScreen/PeerInfoScreenCallActions.swift |
175, 178, 193 | (see prior lines, same pattern set) | (same) |
TelegramBaseController/TelegramBaseController.swift |
243, 246, 258 | peer.peer is TelegramGroup / as? TelegramChannel, case .group = peer.info / as? TelegramChannel, case .broadcast = peer.info |
(same enum-pattern rewrites as above) |
Two name-shadowing notes:
- Inner
peershadowing. Several rewrites (e.g.,else if let peer = peer.peer as? TelegramChannel) currently shadow the loop variable with a newpeerof typeTelegramChannel. After rewrite these becomeelse if case let .channel(channel) = peer.peer— the binding name moves frompeertochannelto avoid further shadowing of the EnginePeer loop variable. Adjust subsequent body references inside the if-let scope (they currently saypeer.inforeferring toTelegramChannel.info; they becomechannel.info). Spot-check each rewrite within its block. channel.inforeferences. When a downcast block uses the boundpeerfor.infoaccess (e.g., line 178:peer.info), update those references to use the new binding name (channel.info). Block-internal-only — no cascade.
Plus 6 filter sites inside SearchPeers.swift _internal_searchPeers body (already counted under §1).
C4 — constructor edits (6 sites):
Bridge-drop sites — wave-33 added ._asPeer() because the value was already EnginePeer; with this wave the field accepts EnginePeer directly:
| File | Line | Current | After |
|---|---|---|---|
TelegramCallsUI/VideoChatScreen.swift |
1833 | FoundPeer(peer: peer._asPeer(), subscribers: nil) |
FoundPeer(peer: peer, subscribers: nil) |
ContactListUI/ContactListNode.swift |
1485 | FoundPeer(peer: mainPeer._asPeer(), subscribers: nil) |
FoundPeer(peer: mainPeer, subscribers: nil) |
ContactListUI/ContactListNode.swift |
1517 | FoundPeer(peer: $0._asPeer(), subscribers: nil) (inside peers.map { … }) |
FoundPeer(peer: $0, subscribers: nil) |
TelegramBaseController/TelegramBaseController.swift |
208 | FoundPeer(peer: peer._asPeer(), subscribers: nil) |
FoundPeer(peer: peer, subscribers: nil) |
PeerInfoScreen/PeerInfoScreenCallActions.swift |
156 | FoundPeer(peer: peer._asPeer(), subscribers: nil) |
FoundPeer(peer: peer, subscribers: nil) |
PeerInfoScreen/PeerInfoScreenCallActions.swift |
265 | FoundPeer(peer: peer._asPeer(), subscribers: nil) |
FoundPeer(peer: peer, subscribers: nil) |
Wrap-needed sites — value at the call site is raw Peer, must be wrapped:
| File | Line | Current | After |
|---|---|---|---|
ContactListUI/ContactListNode.swift |
1506 | mappedPeers.append(FoundPeer(peer: peer.peer, subscribers: subscribers)) |
already-EnginePeer (since peer: FoundPeer after migration) → mappedPeers.append(FoundPeer(peer: peer.peer, subscribers: subscribers)) — no edit |
SettingsUI/StorageUsageExceptionsScreen.swift |
288 | FoundPeer(peer: peer, subscribers: subscriberCount) |
FoundPeer(peer: EnginePeer(peer), subscribers: subscriberCount) |
Note: ContactListNode:1506 is inside a for peer in mappedPeers over [FoundPeer], so peer.peer is already EnginePeer after migration. No edit. Re-classified from C4-wrap-needed to no-op.
So: 4 bridge-drop edits + 1 actual wrap (StorageUsageExceptionsScreen:288) = 5 C4 edits, not 6.
C3 — drop redundant EnginePeer(peer.peer) wrap (22 sites).
After migration peer.peer is already EnginePeer, and EnginePeer.init(_ peer: Peer) does not accept an EnginePeer argument — so each EnginePeer(peer.peer) wrap MUST be dropped to just peer.peer or the build fails.
| File | Line | Wraps | Pattern (representative) |
|---|---|---|---|
SettingsUI/StorageUsageExceptionsScreen.swift |
173 | 1 | EnginePeer(peer.peer).displayTitle(…) → peer.peer.displayTitle(…) |
SettingsUI/StorageUsageExceptionsScreen.swift |
176 | 1 | iconPeer: EnginePeer(peer.peer) → iconPeer: peer.peer |
TelegramBaseController/TelegramBaseController.swift |
265 | 2 | peer: EnginePeer(peer.peer), title: EnginePeer(peer.peer).displayTitle(…) → peer: peer.peer, title: peer.peer.displayTitle(…) |
PeerInfoScreen/PeerInfoScreenCallActions.swift |
201 | 1 | peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …) → peerAvatarCompleteImage(… peer: peer.peer, …) |
PeerInfoScreen/PeerInfoScreenCallActions.swift |
202 | 1 | text: EnginePeer(peer.peer).displayTitle(…) → text: peer.peer.displayTitle(…) |
PeerInfoScreen/PeerInfoScreenCallActions.swift |
288 | 2 | .secondLineWithValue(EnginePeer(peer.peer).displayTitle(…)) and peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …) |
ChatListUI/ChatListSearchListPaneNode.swift |
1075 | 2 | peer: .peer(peer: EnginePeer(peer.peer), chatPeer: EnginePeer(peer.peer)) |
ChatListUI/ChatListSearchListPaneNode.swift |
1076 | 1 | interaction.peerSelected(EnginePeer(peer.peer), nil, nil, nil, false) |
ChatListUI/ChatListSearchListPaneNode.swift |
1078 | 1 | interaction.disabledPeerSelected(EnginePeer(peer.peer), nil, …) |
ChatListUI/ChatListSearchListPaneNode.swift |
1081 | 1 | peerContextAction(EnginePeer(peer.peer), .search(nil), node, gesture, location) |
ChatListUI/ChatListSearchListPaneNode.swift |
3088 | 1 | filteredPeer(EnginePeer(peer.peer), EnginePeer(accountPeer)) (only the FoundPeer wrap drops; the EnginePeer(accountPeer) wrap stays — accountPeer is a raw Peer) |
ChatListUI/ChatListSearchListPaneNode.swift |
3096 | 1 | same pattern as 3088 |
ChatListUI/ChatListSearchListPaneNode.swift |
3214 | 1 | same pattern as 3088 |
ChatListUI/ChatListSearchListPaneNode.swift |
3216 | 1 | entries.append(.localPeer(EnginePeer(peer.peer), …)) |
ChatListUI/ChatListSearchListPaneNode.swift |
3241 | 1 | same pattern as 3088 |
TelegramCallsUI/VideoChatScreenMoreMenu.swift |
171 | 2 | .secondLineWithValue(EnginePeer(peer.peer).displayTitle(…)) and peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …) |
TelegramCallsUI/VideoChatScreenMoreMenu.swift |
658 | 1 | peerAvatarCompleteImage(… peer: EnginePeer(peer.peer), …) |
TelegramCallsUI/VideoChatScreenMoreMenu.swift |
679 | 1 | text: EnginePeer(peer.peer).displayTitle(…) |
| Total | 22 |
Note: only the inner EnginePeer(peer.peer) is dropped. Adjacent EnginePeer(<other>) wraps (e.g., EnginePeer(accountPeer) at lines 3088/3096/3214/3241) are unrelated to this wave and remain.
Total semantic-edit count
- §1 (TelegramCore): struct (3 lines) + 6 filter rewrites + 4 constructor wraps = ~13 spot edits in one file
- §2 C2: 30 consumer-site downcast rewrites
- §2 C4: 5 consumer-site constructor edits (4 bridge-drops + 1 wrap)
- §2 C3: 22 consumer-site
EnginePeer(peer.peer)wrap drops
Total: ~70 semantic edits across 1 TelegramCore file + 7 consumer files. Type-name mentions in signal/collection signatures need no edit; the type continues to compile.
Verification
- Build:
source ~/.zshrc 2>/dev/null; python3 build-system/Make/Make.py --overrideXcodeVersion --cacheDir ~/telegram-bazel-cache build --configurationPath build-system/appstore-configuration.json --gitCodesigningRepository git@gitlab.com:peter-iakovlev/fastlanematch.git --gitCodesigningType development --gitCodesigningUseCurrent --buildNumber=1 --configuration=debug_sim_arm64 --continueOnError - Expected outcome: first-pass-clean build. Errors that surface most likely indicate (a) a missed C2 site, (b) a FoundPeer field-access I missed in the inventory, or (c) a downstream API receiving
peer.peerthat requires rawPeer(would need a._asPeer()bridge added). - Post-build grep validations:
grep -rn "FoundPeer(peer:.*\._asPeer()" submodules/→ expect zero hits in production code (the 4 bridge-drops succeeded).grep -nE "peer\.peer\s+(as\?|is)\s+Telegram" <touched-files>→ expect zero hits in the 7 touched consumer files (FoundPeer-relevant downcasts all rewritten). Other unrelatedsomething_else.peer.peer as?patterns may remain onRenderedPeeretc.grep -rn "EnginePeer(peer\.peer)" submodules/ --include="*.swift" | grep -v "^submodules/TelegramCore/"→ expect zero hits in the 7 touched consumer files (other files keep their wraps because theirpeeris non-FoundPeer).
Risks and mitigations
- Misnamed enum case bindings (C2). A wrong binding name (e.g.
if case let .channel(c) = peer.peerthen accessingchannel.info) compiles but is a typo. Mitigation: the rewrites are mechanical and each table-row in §2 above shows the exact target form. Each binding is reused inside the sameif case letclause. - Hidden field accesses missed by the inventory. Mitigation:
--continueOnErrorbuild catches everything in one pass. If 5+ unexpected error sites surface, abandon and re-inventory. If only 1–2 surface, fix in place. - Downstream APIs requiring raw
Peer. Some consumer code may passfoundPeer.peerto a function taking thePeerprotocol. Inventory found 2 such sites already simplified (C3), but unknown sites may exist. Mitigation: if surfaced by build errors, bridge with._asPeer()at the call site (acceptable transitional pattern — these become next-wave candidates for downstream migration). - Equatable behavior change.
Peer.isEqual(_:)is the protocol's polymorphic identity test;EnginePeer.==is the synthesized-or-manual enum equality. Mitigation:EnginePeer.==is the canonical equality on the enum and is used throughout the engine codebase. The two should agree on identity-relevant fields (peer id, namespace), and FoundPeer equality is used inEquatableset/array dedup contexts where both forms produce the same answer for distinct peers. If tests existed, this would be the place to add one — they don't, so we accept the substitution.
Out-of-scope cleanups (for future waves)
- The downstream
peerAvatarCompleteImage(account:peer:size:)inPeerInfoScreenCallActions.swift:202acceptsEnginePeer— no change needed there. - Wave 33's 5th
._asPeer()bridge (the one not at aFoundPeerconstructor) remains. It is at a different downstream API — separate wave. SendAsPeer,makePeerInfoController,makeChatRecentActionsController,makeChatQrCodeScreenmigrations — each is its own wave, larger blast radius.