Adds three thin forwarding methods on TelegramEngine.Resources (fetch, status, data) over MediaBox, then migrates SaveToCameraRoll's three public functions to use them, drops import Postbox from the module (source + Bazel dep), and updates all 23 call sites across 14 caller files atomically. Bundled: spec + fix + plan + C1 facades + C2 SaveToCameraRoll rewrite + BUILD dep drop + CLAUDE.md outcome. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 KiB
CLAUDE.md
This file provides guidance to AI assistants when working with code in this repository.
Build
The app is built using Bazel.
Code Style Guidelines
- Naming: PascalCase for types, camelCase for variables/methods
- Imports: Group and sort imports at the top of files
- Error Handling: Properly handle errors with appropriate redaction of sensitive data
- Formatting: Use standard Swift/Objective-C formatting and spacing
- Types: Prefer strong typing and explicit type annotations where needed
- Documentation: Document public APIs with comments
Project Structure
- Core launch and application extensions code is in
Telegram/directory - Most code is organized into libraries in
submodules/ - External code is located in
third-party/ - No tests are used at the moment
Postbox → TelegramEngine refactor (in progress)
A gradual migration is underway to eliminate direct import Postbox from consumer submodules in favor of TelegramEngine. Waves landed so far:
- Wave 1 (2026-04-16): first leaf-module cohort — 4 done, 6 abandoned.
- Spec:
docs/superpowers/specs/2026-04-16-postbox-to-telegramengine-refactor-wave-1-design.md - Plan:
docs/superpowers/plans/2026-04-16-postbox-to-telegramengine-refactor-wave-1.md
- Spec:
- Wave 2 (2026-04-17):
MediaResource→EngineMediaResourcefacade migration — 5TelegramEnginefacades migrated, 1 utility module fully de-Postboxed, 1 consumer signal type swapped, 1 task abandoned.- Plan:
docs/superpowers/plans/2026-04-17-mediaresource-to-enginemediaresource-wave-2.md
- Plan:
Rules that apply to every wave
TelegramCoredoes not@_exported import Postbox. Once a consumer dropsimport Postbox, every remaining Postbox-type reference must use an engine-typealiased equivalent.- Never typealias
Postbox,Account, orMediaBox. These umbrella types rename without encapsulating. Narrow utility typealiases (MemoryBuffer,PostboxDecoder,PostboxEncoder,AdaptedPostboxDecoder,MediaResource, …) remain allowed and expected. - No new engine wrapper structs unless the wave's spec explicitly allows — only typealiases and thin forwarding methods.
- Discovery first: before adding any new engine wrapper/typealias, grep
submodules/TelegramCore/Sources/TelegramEngine/for existing equivalents. Record the search result in the commit message. - Abandonment protocol: if a module can only be refactored by violating rule 2 or by editing a module outside the current wave's list, mark the task Abandoned with a recorded reason. Do NOT substitute a new module mid-wave.
- Full project build per module. No unit tests exist in this project.
- TelegramCore never imports UIKit/Display.
TelegramCoreis shared with the Telegram-Mac codebase; its Bazeldepsand source files must not reference UIKit, Display, or any Apple-UI framework. UIKit-needing helpers (image scaling, rendering, etc.) stay in consumer-side submodules.
Engine typealias cheat sheet (existing aliases)
PeerId → EnginePeer.Id
MessageId → EngineMessage.Id
MessageIndex → EngineMessage.Index
MessageTags → EngineMessage.Tags
MessageAttribute → EngineMessage.Attribute
MessageFlags → EngineMessage.Flags
MessageForwardInfo → EngineMessage.ForwardInfo
MediaId → EngineMedia.Id
PreferencesEntry → EnginePreferencesEntry
TempBox → EngineTempBox
PinnedItemId → EngineChatList.PinnedItem.Id
MemoryBuffer → EngineMemoryBuffer (added 2026-04)
PostboxDecoder → EnginePostboxDecoder (added 2026-04)
PostboxEncoder → EnginePostboxEncoder (added 2026-04)
AdaptedPostboxDecoder → EngineAdaptedPostboxDecoder (added 2026-04)
For the MediaResource Postbox protocol, prefer the TelegramCore subtype TelegramMediaResource when the consumer's usage allows (note: EngineMediaResource is a wrapper class, not a typealias, so it is not interchangeable with the protocol).
MediaResource → EngineMediaResource consumer migration
EngineMediaResource is a final class in TelegramCore wrapping a MediaResource value. Unlike the typealiases above it is not interchangeable with the protocol, but it does provide wrap/unwrap helpers:
EngineMediaResource(rawResource)— wrap a rawMediaResource.engineResource._asResource()— unwrap to the rawMediaResource.EngineMediaResource.ResourceData(rawResourceData)— wrapMediaResourceData.EngineMediaResource.Id(rawMediaResourceId)— wrapMediaResourceId.
Pattern for facade functions: when a TelegramEngine.<Area> method leaks raw MediaResource in its public signature, change the facade signature in place to EngineMediaResource (and change any closure parameter types the same way). Bridge inside the facade body by calling the existing _internal_* function with engineResource._asResource() / wrapping raw inputs from inner closures with EngineMediaResource(rawResource). Update all call sites in the same commit. The _internal_* function stays on raw MediaResource — it is the Postbox-facing layer.
Do not add opt-in EngineMediaResource overloads alongside raw-MediaResource overloads. Duplicate signatures fragment the public API and leave the leak in place forever.
For consumer modules, prefer EngineMediaResource as the type in properties, locals, generic arguments and function parameters when the usage is a pure type reference. Do not try to use EngineMediaResource where a class must conform to TelegramMediaResource (Postbox protocol) or override isEqual(to: MediaResource) — those remain import Postbox.
Wave-selection guidance (learned from waves 1 and 2)
The "leaf module, drop Postbox in isolation" approach only works for modules whose public API doesn't leak Postbox domain types. Most candidate leaf modules DO leak such types (postbox: Postbox / account: Account in public inits, Media/Message in public function parameters). Those modules need paired caller-migration waves, not isolated refactors.
Before selecting a wave's module list, grep each candidate for:
:\s*Postbox\b,:\s*Account\b,:\s*MediaBox\bin public signatures → abandon candidateMedia/Messageas public parameter types → likely needs paired wave with callers
Inventory at execution time, not just planning time. Wave 2's SaveToCameraRoll task was planned from a narrow grep that only matched MediaResource/TelegramMediaResource and missed three postbox: Postbox public-function leaks plus multiple postbox.mediaBox.* bodies. Planning-time inventory should grep the full set \b(postbox|mediaBox|transaction|PostboxView|combinedView|MediaResource|PostboxDecoder|PostboxEncoder|MemoryBuffer)\b|^import Postbox over the module's Sources, not just the tokens specific to that wave's goal. If the planning inventory under-counts, the executor should re-inventory at Task-1 time and abandon early before editing code.
Two feasible wave shapes. Wave 1 tried "per-module Postbox drop". Wave 2 tried "per-engine-facade-API migrate MediaResource to EngineMediaResource (modify in place, update all call sites in one commit)". The second shape worked well: narrow, clean commits, no abandonment cascade. Prefer it when the refactor target is an API surface that multiple consumer modules depend on.
Wave 1 outcome (2026-04-16)
4 modules done: ChatInterfaceState, ChatSendMessageActionUI, ContactListUI, DrawingUI.
6 modules abandoned with recorded reasons in the wave-1 plan: ActionSheetPeerItem, ChatListSearchRecentPeersNode, DirectMediaImageCache, FetchManagerImpl, GalleryData, ICloudResources.
Wave 2 outcome (2026-04-17)
5 TelegramEngine facades migrated to EngineMediaResource (signatures changed in place; _internal_* Postbox layer unchanged):
TelegramEngine.Peers.uploadedPeerPhoto,uploadedPeerVideo,updatePeerPhotoTelegramEngine.AccountData.updateAccountPhoto,updateFallbackPhotoTelegramEngine.Contacts.updateContactPhotoTelegramEngine.Auth.uploadedPeerVideo
1 consumer submodule fully de-Postboxed: MapResourceToAvatarSizes (signature changed from (postbox: Postbox, resource: MediaResource, …) to (engine: TelegramEngine, resource: EngineMediaResource, …); 27 call sites migrated).
1 consumer signal type swapped: AuthorizationUI/AuthorizationSequenceController.swift (Signal<TelegramMediaResource?> → Signal<EngineMediaResource?>).
1 task abandoned with recorded reason in the wave-2 plan: SaveToCameraRoll (full-module Postbox coupling, needs its own wave).
Wave 3 outcome (2026-04-18)
3 thin forwarders added on TelegramEngine.Resources over MediaBox:
fetch(reference:userLocation:userContentType:)→Signal<FetchResourceSourceType, FetchResourceError>(Postbox return types remain a documented accepted leak)status(resource: EngineMediaResource)→Signal<EngineMediaResource.FetchStatus, NoError>data(resource: EngineMediaResource, pathExtension:, waitUntilFetchStatus:)→Signal<EngineMediaResource.ResourceData, NoError>(takes aBoolrather than exposingResourceDataRequestOption, per YAGNI)
1 consumer submodule fully de-Postboxed: SaveToCameraRoll. Public signatures changed from (context:, postbox: Postbox, userLocation:, …) to (context:, userLocation:, …); FetchMediaDataState.data payload changed from MediaResourceData to EngineMediaResource.ResourceData; internals rewired through context.engine.resources.*. 23 call sites across 14 files migrated atomically with the module.
Pre-flight verified that ShareController.swift:2406's self.currentContext.stateManager.postbox is equivalent to context.account.postbox in the ShareControllerAppAccountContext path (because AccountStateManager is constructed with the account's own postbox), so the postbox: argument could be dropped without behavior change.
No tasks abandoned. Shape validated: "per-engine-facade-API migration + full consumer module rewrite" (the wave-2 shape, scaled up to a full module drop).
Plan: docs/superpowers/plans/2026-04-18-postbox-to-telegramengine-wave-3.md
Modules currently free of import Postbox (running tally)
Consumer modules that no longer import Postbox, across all waves and standalone commits:
ChatInterfaceState(wave 1)ChatSendMessageActionUI(wave 1)ContactListUI(wave 1)DrawingUI(wave 1)StickerPeekUI(standalone cleanup, 2026-04-17 — import was unused)PromptUI(standalone cleanup)PresentationDataUtils(standalone cleanup)MapResourceToAvatarSizes(wave 2)SaveToCameraRoll(wave 3)
Known future-wave candidates
Surfaced by the wave-2 final review:
TelegramEngine.Stickers.uploadSticker(peer: Peer, resource: MediaResource, thumbnail: MediaResource?, …)— same MediaResource migration as wave 2, pluspeer: Peerwhich would naturally migrate toEnginePeerat the same time. Self-contained to a small number of call sites.submodules/TelegramCore/Sources/TelegramEngine/SecureId/UploadSecureIdFile.swift: public func uploadSecureIdFile(…, postbox: Postbox, …, resource: MediaResource)— rule-2-sensitive (umbrella-type leak). Needs a paired wave with its caller(s).- Classes conforming to
TelegramMediaResource(needisEqual(to: MediaResource)override) remain permanently blocked from consumer-side migration:ICloudFileResource,InstantPageExternalMediaResource,VideoLibraryMediaResource,YoutubeEmbedStoryboardMediaResource. Either move the class intoTelegramCoreor keepimport Postboxin its module.
Build environment quirk
The build needs TELEGRAM_CODESIGNING_GIT_PASSWORD in the environment. It is set in ~/.zshrc but Claude Code's bash tool does NOT source shell config by default. Prefix build commands with source ~/.zshrc 2>/dev/null; to pick it up.