Skip to content

Release v12.9.2-3901 — Телеграм - #41

Draft
decoder-dev wants to merge 75 commits into
masterfrom
dec/zalupa
Draft

Release v12.9.2-3901 — Телеграм#41
decoder-dev wants to merge 75 commits into
masterfrom
dec/zalupa

Conversation

@decoder-dev

@decoder-dev decoder-dev commented Aug 20, 2026

Copy link
Copy Markdown
Owner

PatriotPlaneIcon v3

Updated the custom Patriot alternate app icon with higher detail:

  • Soldier in tactical plate carrier / body armor with pouches and MOLLE webbing
  • Ushanka with double-headed eagle emblem
  • Sleeve chevron: Russian tricolor + РФ
  • Paper plane launch (Telegram motif)
  • Russian flag backdrop, detailed cartoon shading

Also fixes missing TelegramUIPreferences import for the localized «Патриот» / Patriot label in icon settings.

Preview: /opt/cursor/artifacts/telegram-icon-patriot-plane-1024.png

Open in Web Open in Cursor 
Reserve left-of-avatar slots for Calls outgoing/voice-chat icons so they
no longer clamp under the 52pt photo. Match the composer capsule to the
40pt side circles (2/2 insets). Unify Day/Night accent and folder pill on
system blue; make Night incoming links readable; center Contacts thread
avatar icons on the avatar frame.
Idealism pass: Calls/Contacts overlaps, composer 40pt, unified #007AFF.
Stock Telegram parked send at capsule.maxX − width so the blue disc ate
the pill’s rounded end. Place send at maxX + 6 with a reserved right slot
(8 + size + 6) for both empty and has-text — capsule width no longer jumps
on the first character, emoji stays inside without clearing an overlay, and
ActionButtons returns the true 40pt circle (title pills still expand).
Replace the left paperclip with a drawn Plus (Messages). Keep mic and send
in one right-hand slot and crossfade/scale them in place — drop the off-screen
mic slide, paper-plane fly-in, and blur. Z-order swaps with recording so
lock/stop stay hittable. Telegram attach menu, video notes, long-press send,
bots, slowmode, and paid send stay.
Recording lock/stop live on mediaActionButtons; if send was above it from
a prior text state, hits would miss the mic chrome. Always elevate media
when recording (or when send is not in the slot).
Icon audit: cross-referenced all 823 bundleImageName literals against the
1511 imagesets in the tree. Five names resolve to nothing.

  Star                        the call-rating success HUD asked for a bare
                              "Star"; the asset is "Call/Star", so the HUD
                              showed text with no glyph. Path corrected.
  Media Editor/MediaVideo     the picker grid's video, slo-mo and time-lapse
  Media Editor/MediaSlomo     badges. No imageset by those names ships (the
  Media Editor/MediaTimelapse only MediaVideo in the tree belongs to the watch
                              target), so every video thumbnail drew its
                              duration beside an empty 19 pt square. Now taken
                              from SF Symbols - video.fill, slowmo, timelapse -
                              the same three Photos uses for the same three
                              subtypes.
  Settings/Permissions/Siri   genuinely absent and left absent: the sibling
                              permission screens use full-colour illustrations
                              and no substitute matches. PermissionContentNode
                              already lays out cleanly for a nil icon.

Chat List/Tabs/Holiday/* are also absent but unreachable - useSpecialTabBarIcons
gates them on 24 Dec 2020 to 2 Jan 2021, a window that cannot recur.

Calls list avatar column. The overlap fix reserved the glyph slot per row:
CallListCallItem only when the row had an outgoing arrow and at that arrow's
own 20 pt, CallListGroupCallItem always at the indicator's 22 pt. Three kinds
of row in one list therefore had three avatar origins - 16, 42 and 44 pt - and
the avatars stopped forming a column. Both items now reserve one shared 22 pt
slot unconditionally and centre their glyph inside it: avatars land at 44 pt
everywhere, the 20 pt arrow sits at +1 within its slot.

Composer action slot. frameForInputActionButton() decided the mic was visible
by testing frame.minX against the bounds. That worked while the mic was parked
at width + 8; it now stays in the slot and collapses under a scale transform,
so its frame degenerates to a sub-point rect at the slot centre - still inside
the bounds. Tooltips and recording affordances would anchor to an invisible
control. The panel now records the slot rect during layout and reports nil when
send owns the slot. The z-order swap between mic and send also ran an
unconditional insertSubview on every layout pass, i.e. on every keystroke; it
now reorders only when the two are actually inverted.

Contrast. outgoingSecondaryTextColor is white at 0.5 in Night and 0.65 in Day -
alphas tuned against the stock outgoing bubble, which is darker than the #007AFF
this fork pins. On that blue they measure 2.02:1 and 2.50:1, so the timestamp
and ticks stop being readable. Both to 0.8, i.e. 3.08:1. The voice waveform's
unplayed part rode on the same value and reads by its distance from the played
part rather than absolute legibility, so it keeps its old alpha through a
separate outgoingInactiveControlColor. Night's three list hairlines go from
#545458 at 0.55 - Apple's value, which assumes a #1C1C1E ground - to 0.8, since
plain lists and the chat list here sit on pure black where 0.55 is 1.55:1.
Moving send outside the capsule left three cases deriving the right-hand
reservation from whichever control happened to be showing.

.empty is the serious one. It means "no custom right control", not "no send
button" — MessageInputPanelComponent sets it whenever sendStarsAction is nil,
which covers the story reply and story camera composers, and those delegate
their send button to this node. Its branch kept a flat 8 pt right inset, which
was correct only while send was drawn at maxX − width, inside the pill. With
send at maxX + 6 the capsule runs to 8 pt from the panel edge and the 40 pt
button starts 2 pt short of it: typing a story reply put roughly 38 of those
40 pt past the edge.

The stars and live-mic branches reserved their own widths, but those controls
park off-screen the moment there is text — which is exactly when send arrives
in the same slot at a different width. And the has-text branch reserved send
while the empty branch reserved mic, so any width difference between them
resized the capsule on the first keystroke, the very thing moving send outside
was meant to stop.

All four collapse into one reservation: 8 pt margin, 6 pt gap, and a slot as
wide as the widest control that can occupy it. Every case keeps the geometry it
already had except .empty, which gains the 46 pt it was missing.

Two things fell out of that. hasForward was left with no readers once its
branch went, so sendOccupiesActionSlot now uses it instead of recomputing
forwardMessageIds != nil inline — same value, and it would otherwise have
failed the build under -warnings-as-errors. And effectiveActionButtonsSize
survived only to shrink the recorded-voice preview inside the capsule, a
clearance the preview needed when send overlapped the pill; with send outside
it was 46 pt of dead space at the end of every waveform.

Night placeholder text. The palette sweep set itemSecondaryTextColor,
itemDisabledTextColor and itemPlaceholderTextColor all to #8E8E93, so an empty
field's prompt reads exactly like a filled field's secondary label. Day keeps
them apart at #8E8E93 against #C8C8CE, about half the contrast; #636366
mirrors that ratio on a #1C1C1E card, 5.22:1 against 2.84:1. Disabled matching
secondary is upstream behaviour in Day too, so that pair is left alone.
A custom right action (stars, or .empty) takes the right slot over, so the mic
node is removed from layout entirely rather than faded. That gate was written
as a bare 'if customRightAction != nil { isHidden = true }' with no else, while
customRightAction itself is assigned in both directions by
ChatTextInputPanelComponent — including back to nil. Any node that made that
transition kept its microphone hidden for the rest of its life.

Everything else driving the mic in this method works in both directions
already, through alpha and scale; the gate now matches.
Messages composer polish, Calls column, contrast, icon fixes, story-reply slot.
The chat-list folder tab strip picks its selection pill's frame with an index
taken from reorderedFilters, then bounds that index against filters.count
before reading selectionFrames[currentIndex + 1]. Three different arrays.

They do line up today. The reconciliation earlier in the method rebuilds
reorderedItemIds to exactly the set of ids in filters, and the loop that fills
selectionFrames can only skip a filter whose item node is missing, which the
loop immediately before it has just created for every filter. So the bound is
correct — by a chain of three unstated invariants living in three separate
places, any one of which drifting gives an out-of-range read mid-drag.

Bounding against selectionFrames itself cannot drift, and the sibling branch
higher up in the same method already bounds against the right array. No
behaviour change while the invariants hold.
Wrapping. calculateTextFieldMetrics decides how tall the field must be by
measuring the text at the width the field will get. It called
textFieldInsets(metrics:) itself, which returns the base insets — 8 pt on the
right. Layout then overwrote that right inset with 54 pt to reserve the action
slot. So the height was measured for a line 46 pt wider than the field really
is, and the measure pass also skipped the 10 pt trim layout applies to the
in-field accessory clearance: 36 pt of disagreement in total.

The visible effect is text that wraps onto a second line while the panel still
believes it fits on one, so the field does not grow to meet it.

The inset block now resolves before the measurement rather than after it, and
is handed to calculateTextFieldMetrics instead of being recomputed there. The
accessory clearance moves into one resolvedTextInputRealInsets() shared by both
passes, so the trim cannot be applied on one side only. updateTextHeight, which
re-measures outside a layout pass, reads the last resolved insets from a stored
property, exactly as it already does for leftMenuInset and rightSlowModeInset.

Separators. Telegram insets row separators 16 pt on the trailing side, so every
hairline in the chat list, Calls and Contacts stopped short of the edge and read
as an unfinished line rather than a divider. iOS tables — and Messages, which
this fork follows — inset only on the leading side. All three lists now run to
the trailing edge; the leading inset, which aligns the hairline with the row's
text, is unchanged.
Field height measured on a different width than the field gets, part two.
updateTextHeight re-measures the composer outside a layout pass, and its
baseWidth carried two terms updateLayout's does not: an offset fed from a
hardcoded 0.0, dead since it was introduced, and a full subtraction of
additionalSideInsets.right, which double-counts now that the resolved insets
are passed in — those already carry that inset, and carry it as the third
updateLayout actually applies rather than the whole of it. Both formulas are
now character for character the same expression, and the dead offset is gone
rather than left to be re-derived by the next reader.

Attachment slot reserved for a button parked off-screen. attachmentButtonX
sends the button to -8 - leftButtonsWidth for either of two reasons: media
recording, or !displayMediaButton. Only the recording half had a matching
branch clearing the 46 pt reservation, so the one state that sets
displayMediaButton = false — the business-link composer — kept a gap on the
capsule's left where no button is drawn.

Cloud-password fields invisible on black. The OLED pass flattened
freeInputField and freePlainInputField to a #000000 fill. Every consumer of
those two draws the field as generateStretchableFilledCircleImage(color:
backgroundColor) and never reads strokeColor — the Passport password box in
SecureIdAuthPasswordOptionContentNode, and both cloud-password fields in
TwoFactorAuthDataInputScreen — so the boxes were black on a black page, and
the only state that showed an edge was failure, which paints its own red tint.
Back to a #1C1C1E fill, matching itemInputField, whose greys they now share as
well; the last two #98989E in the theme went with them.
The screenshot settled it, and not the way I guessed: the deciding term was
hasSolidWallpaper, not displayHeader. Stock draws the bubble behind media on
any wallpaper that is not a flat colour or a two-stop gradient, so the same
photo gets a frame of bubble colour in a chat with a picture background and no
frame at all in a chat on the default one. Nothing about the message decides
it — only which chat it landed in, which is why some sent photos had the
outline and others did not.

On stock's blue gradient that frame reads as the bubble. On this fork's pinned
flat #007AFF it reads as a blue outline drawn around the image.

hideBackground now depends only on whether there is a header to hold. The
header term stays: a reply preview or author name needs the bubble behind it to
stay readable. The date falls back to the floating pill, which the theme
already defines separately for custom wallpapers — serviceMessage.components
.withCustomWallpaper.dateFillFloating, an opaque #2C2C2E, so it stays legible
over any picture.

This widens a path that is already the default rather than opening a new one:
both shipped themes use a solid background, so bubble-less media is what every
user on the default wallpaper already sees. hasSolidWallpaper had no other
reader and is removed rather than left to be written and ignored.

Incoming media on a custom wallpaper loses its frame too, by the same rule.
Worth a look on a device with a picture background, in both directions, since
this is message rendering and I cannot see it from here.
Composer wrap/measure, separators, media bubble rule, password fields, folder bound.
Alternate app icons are the one Premium exclusive in this codebase that is
entirely client-side. The artwork ships inside the bundle, selection goes
through UIApplication.setAlternateIconName, and nothing about the choice is
requested from, granted by or recorded on the server — so the Premium marker on
some of them locked an asset the build already contains.

Two gates, both in ThemeSettingsController: the entry passed the account's real
premium flag to ThemeSettingsAppIconItem, which drew the padlock badge, and
selectAppIcon looked the account up purely to decide between applying the icon
and pushing PremiumDemoScreen instead. The first now passes true; the second
applies directly, and the peer lookup that existed only for that branch is
gone with it.

Left alone deliberately: the isPremiumDisabled filter above, which removes
these icons from the list entirely where the server says Premium is not offered
at all. That is a different question from locking them, and this change is
about the padlock.
Swiping between tabs was clean and tapping one duplicated the list. That
asymmetry is the whole clue, and it points at chatListLocationGeneration — a
counter this fork invented that upstream has no equivalent of.

How it fails. mapToSignal captures the counter when it subscribes, tags every
emission of that chatListViewForLocation with it, and mapToQueue drops any
emission whose tag no longer matches. The counter is bumped by
setChatListLocation. But the location feeding mapToSignal goes through
distinctUntilChanged, so setting a location equal to the current one bumps the
counter without producing a new subscription — and the live signal is left
tagged with a number that can never match again. From that moment every update
it produces is dropped, previousView stops advancing while the list keeps
rendering, and the next real location change diffs against a base that is stale
by however many updates were swallowed. Rows already on screen get inserted a
second time.

Only one of the eight setChatListLocation call sites checks that the location
actually changed — the pagination one. The scroll-to-top pair does not, and a
tab tap reaches it with identical parameters when the list is already at top.
A swipe never goes near it, which is exactly the reported asymmetry.

The guard was never needed. mapToSignal disposes the previous
chatListViewForLocation the moment the location changes, so a superseded view
cannot arrive after a switch — that is what mapToSignal is for. Upstream has no
filter anywhere along this path. Both halves are removed: the tag is gone from
the tuple, the guard is gone from mapToQueue, and setChatListLocation is back to
two lines. Verified mapToSignal, the destructure and dequeueTransition are now
character for character upstream's.

The comment in dequeueTransition described the old mechanism and is rewritten to
describe the invariant instead.
Audited the fork's own divergence from upstream rather than the app at large:
325 files carry a real hand-written difference, 17413 added lines. Two findings,
both the same shape as every other one this session — a value computed in one
place and then re-computed by hand somewhere else.

ghostMode. A stored Bool mirroring ghostDontReadMessages && ghostDontSendOnline
&& ghostDontSendTyping, with that expression written out six times: decode,
encode, the memberwise init, and three separate settings mutations — plus
isFullGhostMode, which is the same expression under a better name. Nothing reads
it: Ghost Mode is driven from the three raw flags in SharedAccountContext, and
neither isFullGhostMode nor suppressesMessageReads has a single consumer. A
stored copy of a derived value is one forgotten assignment away from disagreeing
with what it mirrors, and a fourth ghost toggle would have been exactly that
assignment. It is computed now, delegating to isFullGhostMode; the encode stays
so an older build still reads a coherent value out of the blob. Equatable is
synthesised and its behaviour is unchanged, since the three flags it derives
from are still compared.

Forwarded media size. convertMediaForAyuForward fell back to the filesystem size
when a file arrived without one, handed that to LocalFileReferenceMediaResource,
and then built the TelegramMediaFile with the original file.size — so a file with
no size produced a resource that knew how big it was and a media object that did
not. One resolvedSize now feeds both.

Swept clean, reported as such rather than padded: every force-unwrap the fork
added (15) is safe by construction — literal keys in a dictionary literal
declared beside them, or a dictionary pre-populated from allCases immediately
above, which is upstream's own idiom at 98 other sites in the same file. No
try!, three as! on statically known types, six fatalError/preconditionFailure all
in unreachable protocol stubs. Every Timer, CADisplayLink and NotificationCenter
observer the fork added either has matching teardown or is a documented,
idempotent process-lifetime singleton.
…ls 44

- Drop leftover Int from ChatListNode filter map (compile break after folders guard drop).
- Align sendOccupiesActionSlot with keepSend/slowmode/search; reserve paid-stars width; tooltip respects mic isHidden.
- Apply base Night/Day outgoing white@0.8 and Night chrome separators @0.8 on the default theme path.
- Calls avatars 44pt; Contacts thread rows keep the shared left column.
Unify mic↔send morph on 0.18s easeInOut, gate hits to the occupying
slot, use schedule icons (not filled discs) on the blue well, size
send/apply/schedule icons and the stretchable disc to 40pt, align
pointer circles, rename media hide case to whenNoHeader, and match
Night waveform inactive to Day @0.65.
Release Swift treats unused binding as error (#no-usage). After the
folders generation guard drop the weak-self capture was only a nil
gate — use a boolean test instead.
Previous commit only updated the changelog note; the #no-usage
binding remained. Gate with `self != nil` so the release IPA builds.
CDN decryption. MTAesCtrDecrypt passes both buffers straight to MTAesCtr with a
hardcoded keyLength:32, and the initializer reads a full AES block out of the IV
(MtProtoKit/Sources/MTEncryption.m:713). Neither encryptionKey nor encryptionIv
is validated anywhere upstream of the call, and both arrive in the CDN redirect
response, so a short buffer is an out-of-bounds read inside the C layer on an
ordinary media download that gets redirected to a CDN datacenter.

The existing guard covered only the memcpy on the Swift side and asked for four
bytes, so an IV between 4 and 15 bytes passed it and still under-ran the block
read. Both sites now require exactly 16 bytes of IV and exactly 32 of key before
calling, failing through each file's own error case.

Round video transcription. animateTo gated its whole hand-off block on
snapshotView(afterScreenUpdates:) returning non-nil - including the completion
that sets isHidden and canAttachContent. That call returns nil for a zero-sized
or off-screen view, and when it did the node stayed in the state it had already
been animated out of. Only the crossfade depends on the snapshot now.

Also documented, not changed: ChatTextInputPanelComponent's primary rightAction
switch assigns .liveMicrophone to the *secondary* slot. That reads like a typo
and I nearly corrected it - but ChatTextInputPanelNode only reads .liveMicrophone
out of customSecondaryRightAction, and any non-nil customRightAction hides the
mic, so the corrected-looking version would hide the mic and draw nothing. The
existing routing is the one that would work; it now says why.

Attempted and reverted: removing higChatListCardInset from ChatListItem, which
returns 0.0 from all three branches and is threaded through eight sites. A
mechanical edit corrupted the expression "width - higCardInset * 2.0" into
"width * 2.0". It is inert dead weight with no behavioural cost, and removing it
correctly needs per-site edits for no functional gain, so it stays.
In dual-camera round-video mode two threads run processVideoRecording on the
same instance - the master's own videoQueue, and the additional output's
videoQueue reaching the master through masterOutput.processVideoRecording. The
composition branch ran entirely unguarded.

Two of the fields it touches are read-modify-write, and those are corruption,
not just torn reads:

lastSampleTimestamp - read, compared, then conditionally appended and written.
Both threads can read the same value, both pass the ordering check and both
append, putting out-of-order frames into the recording. The append has to be
inside the critical section; leaving it out puts the interleaving straight back.

needsCrossfadeTransition - checked and cleared. Both threads can see it set,
both clear it, and both arm a crossfade.

Guarded by a dedicated compositionSemaphore rather than the existing one:
processRoundVideoSampleBuffer takes `semaphore` internally, so holding that
across the branch would deadlock on a non-recursive semaphore. The two field
sets do not overlap, and the new lock is never held across a call that takes the
old one, so they cannot form a cycle. markPositionChange writes currentPosition
and lastSwitchTimestamp from the camera-control layer while both capture queues
read them every frame, so it takes the lock too; it is not reachable from
processVideoRecording, so there is no re-entrancy.

Deliberately not locked: the plain reads of currentPosition, lastSwitchTimestamp
and crossfadeTransitionStart scattered through the branch. Taking a lock around
every read in a 30-60fps callback costs more than it buys, and a stale read there
picks a slightly different transition factor for one frame rather than corrupting
the file. The read-modify-writes were the actual defect.

Not verifiable here - this needs a device with dual-camera round video and, to
prove the race is gone rather than narrowed, a Thread Sanitizer run.
MessageMediaPlaylistItem.playbackData and .displayData are instance lazy vars,
which Swift does not synchronise. Confirmed both readers rather than assuming:

- arePlaylistItemsEqual reaches them from the distinctUntilChanged at
  MediaManager.swift:217, comparing SharedMediaPlayerItemPlaybackStateOrLoading
  on the producing queue. That sits before the pipeline's deliverOnMainQueue,
  not after it.
- MediaNavigationAccessoryHeaderNode and OverlayAudioPlayerControlsNode read the
  same properties on main, after that hop.

Two threads racing the first access can both run the initialiser, or one can
observe a half-written value.

Forced in init, while the object is still private to the constructing thread.
That costs nothing: exactly three of these exist per state update - current,
previous and next, built at PeerMessagesMediaPlaylist.swift:530-535 - and
arePlaylistItemsEqual reads both properties on all three regardless. The work is
identical, it just happens somewhere single-threaded.

Chose this over hoisting both closures into static factories and storing lets.
That is the tidier shape, but it moves eighty lines I cannot compile-check, and
it buys nothing the two-line version does not already guarantee.

Upstream code, not a fork regression.
The intermittent launch crash has been reported several times and is still
undiagnosed, because there is no crash log and guessing at it by reading a
codebase this size is not a method. This makes the next occurrence produce
evidence.

ForkLaunchBreadcrumbs installs as the first statement of
didFinishLaunchingWithOptions, before anything that can fault, and records which
launch stage was running when the process died. Seven stages are marked, from
didFinishLaunchingBegan through didBecomeActive, so a crash narrows to a span of
launch rather than to the whole of it. The record is read back and logged on the
following launch, once Logger.setSharedLogger has run - the file is written
independently of Logger precisely because Logger does not exist yet at the point
where this has to be armed.

Async-signal-safety is the design constraint on the signal path. Almost nothing
is legal inside a handler: no allocation, no locks, no Foundation, no String. So
the file descriptor and the four-byte record buffer are both prepared at install
time, the static the handler touches is initialised there too - first access to a
Swift static goes through swift_once, which takes a lock - and the handler itself
does nothing but fill four bytes and write() them. The NSException handler is not
in signal context and uses Foundation freely.

Every handler restores the default disposition and re-raises, so the operating
system still produces its own crash report; this only adds the one thing that
report cannot carry, which is where in launch the app was. There is no other
signal or exception handler in this app, so nothing competes.

Nothing here changes behaviour on a launch that succeeds: install truncates the
file, a clean run writes nothing to it, and the next launch finds it empty.
The debug screen has always existed but was reachable only through the
tg://settings/debug deep link, which is not something anyone finds without being
told it is there. It now has a row at the end of the Advanced section, below the
everyday settings.

New PeerInfoSettingsSection case, dispatched in PeerInfoScreenSettingsActions to
the same makeDebugSettingsController the deep link uses. Title follows the fork's
existing bilingual pattern, next to forkCustomizationSettingsTitle, because the
localisation catalogue has no entry for this. Icon reuses Item List/Icons/Settings
on grey, matching how Privacy already pairs that colour with a system-ish glyph.

Half that screen was hidden. Two blocks were gated on appBuildType == .internal,
and this fork builds from appstore-configuration.json, where is_appstore_build is
true - so appBuildType resolves to .public and a Developer Mode row would have
opened a screen with pieces missing. Both blocks are now unconditional: reaching
the screen is the opt-in, and gating rows inside it on top of that only produces
a half-populated screen.

Gated there rather than by flipping appBuildType, which has three other readers.
One is a commented-out no-op in ChatListNode. One shows the video quality debug
overlay. The third sets the default for automatic call-reflector benchmarking in
AppDelegate, which starts periodic network work - not something a Settings row
should switch on as a side effect. Only the debug screen's own gates moved.
Both ways of unlocking the Archive end in the same conclusion - the owner proved
who they are - but only one of them cleared the brute-force failure counter. The
password path calls clearFailureState on a match; the biometric path called
ArchiveLockSession.unlock() and nothing else.

So a few wrong password attempts followed by a successful Face ID left the
counter and its cooldown in place, and the next password entry could still be
refused. The counter throttles guessing, not the owner.

Not a bypass in the other direction: the biometric branch cannot be used to skip
a cooldown that is protecting against someone who does not have the password,
because passing Face ID means passing Face ID.

Verified while here, and left alone:

- The trim applied to the entered password is symmetric. Both the set path and
  the verify path trim whitespace and newlines identically, so a password with a
  trailing space cannot be stored and then never match.
- bindBackgroundRelock claims its slot atomically with a placeholder before
  subscribing, so two concurrent callers cannot both subscribe and orphan one
  subscription.
- ManagedAutomaticMtProxy's switch hysteresis reads correctly: a voluntary move
  needs sixty seconds since the last one and a candidate at least thirty per cent
  faster, and a server that actually failed is in excludedActiveServer and skips
  both checks. excludedActiveServer is cleared when the connection comes back
  online and on settings changes, so a server that recovers is not excluded
  forever and the pool does not shrink over time.

Flagged, not changed: archivePasswordHash is a single round of SHA-256 with a
per-account salt. The salt does what its comment claims - one precomputed table
will not work against every account - but without key stretching a short password
still falls quickly if the Keychain is ever extracted. PBKDF2 with a real
iteration count would be the right shape, and the file already has a
hash-generation upgrade path to model a migration on. It is a security-design
change with a migration, on a threat model that starts with the attacker already
holding the Keychain, so it wants a deliberate decision rather than being folded
into a bug-fix pass.
The typing-suppression gate sits at the top of requestActivity, which is the
right place - it is the single chokepoint for outgoing activity, feeding both
messages.setTyping and messages.setEncryptedTyping. But it is not only typing
that passes through it. PresentationGroupCall acquires .speakingInGroupCall on
that same path to drive the live "is talking" highlight other participants see
while you speak in a voice chat.

So turning on "do not send typing" also stopped that indicator. Other people in
the call no longer saw you light up while talking - and nothing in the setting
says it does that. It is about not advertising that you are composing a message,
not about going quiet in a call you already joined.

.speakingInGroupCall is now exempt. Written with the same shape the function uses
a few lines further down, where the broadcast-channel branch already singles out
this one activity.

Checked the two sibling flags for the same over-reach and found none:
suppressOnline is enforced in ManagedAccountPresence at the single site that
calls account.updateStatus, and suppressMessageReads is applied at the read
sites; neither passes through a chokepoint shared with unrelated traffic.
Telegram carries its own language setting, independent of iOS. Every string the
fork added itself - the ones with no entry in the localisation catalogue - picked
Russian or English by reading Locale.preferredLanguages, which is the device's
language. Run iOS in English with Telegram set to Russian and the Archive lock,
the extras settings screen, the saved-messages history screen and two context
menu entries all came back in English among Russian ones. The reverse pairing
gave the mirror image.

Six sites, three of them whole string tables:

  ArchiveLockLocalizedString      the entire Archive lock vocabulary
  ForkExtrasController            the extras settings screen
  MessageSavingHistoryController  the saved-messages history screen
  ChatInterfaceStateContextMenus  "Save to Saved Messages", "Select from Author"
  PeerInfoSettingsItems           the proxy row's Auto label
  DataAndStorageSettingsController  the same label, second copy

ForkPresentationLanguage holds the presentation language, pushed from
SharedAccountContext on every presentation-data emission - the same pushdown the
fork already uses for its cross-module values. Set unconditionally rather than
inside the stringsUpdated branch, because that flag compares against the value
seeded from initialPresentationDataAndSettings, so the first emission can match
and skip, leaving the override nil for the rest of the session. Every reader
still falls back to its old device-language lookup while the override is nil, so
nothing regresses in the window before the first push.

Separately: the Edit History context menu entry was a raw English literal, in a
file where the fork's two other added entries are bilingual. It now goes through
the same helper the Settings rows use, which also gives it uk and be rather than
only ru.
@cursor cursor Bot changed the title Release v12.9.2-3899 — Телеграм Aug 21, 2026
Previous 3900 tag only updated the changelog; the Swift compile error
(var never mutated) was still on the branch tip.

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Same -c opt unused-mutation error as SettingsUI appIcons: the icon
list is now a fixed array, so declare it let.

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
@cursor cursor Bot changed the title Release v12.9.2-3900 — Телеграм Aug 21, 2026
Empty "${RELEASE_FLAGS[@]}" under bash set -u aborts Publish on
non-*-pre tags after a successful archive. Gate optional flags with
a length check and pass --latest only for full releases.

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
…y links

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
…d actions

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
…to servers

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
…down

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
makeIncomingVideoViewWithEndpointId called getSink on a nil cloneRenderer
(requestClone == false), leaving a non-trivial shared_ptr uninitialized.
Only call getSink when cloneRenderer exists.

CallAudioTone::asTone sized its buffer to _samples.length/2 but memcpy'd the
full NSData — one-byte heap overflow on odd-length tones. Copy only what fits.
CallListCallItem only showed a type icon for outgoing calls; incoming and
missed rows had an empty slot. Mirror the outgoing voice/video PDFs 180° for
incoming, tint missed with the destructive color already used in status text.
No new assets — same PresentationResourceKey caching path.
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Add missing .webProxy cases in ChatRecentActionsControllerNode,
TextLinkHandling, and OpenUserGeneratedUrl so release builds compile
and tg://webproxy links open the proxy preview screen.

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
The row was gated behind `!settings.proxySettings.servers.isEmpty ||
settings.proxySettings.autoFetchPublicMtProxy`, so a fresh install (or
after removing every saved server) had no way to even discover proxy
settings from the main Settings screen - the row disappeared entirely
instead of showing "Disabled". The label logic already had a correct
fallback to Settings_ProxyDisabled for this exact state; only the
outer visibility gate needed to go.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LD9rT1B8mc4YRgymdiKmu

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
…Bag/MTBag/DeviceProximityBag)

New logs show the same "Collection was mutated while being enumerated"
crash at didBecomeActive (last one fixed was MTContext's listener
broadcast) recurring after that fix was deployed - so a second,
unguarded enumeration site exists somewhere else.

Found it: NSBag/SBag/MTBag/DeviceProximityBag are four copies of the
same "callback bag" utility, and all four had the identical bug in
enumerateItems: - it iterates the raw _items ivar directly while
invoking each item's block, so if any callback reenters addItem:/
removeItem: on the same bag (unregistering itself, or registering a
new listener in response to the notification), the array is mutated
mid-enumeration and crashes.

NSBag specifically backs the swizzled UINavigationItem/UIBarButtonItem/
UITabBarItem property proxies (title, badge, bar-button listeners) -
exercised constantly as the UI re-syncs on every app resume, which
matches the crash's reported launch stage exactly. Give all four the
same defensive copy already used for MTContext's _changeListeners.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LD9rT1B8mc4YRgymdiKmu

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
The "SAVED PROXIES" section always showed three permanent rows -
"Add SOCKS5" / "Add MTProto" / "Add WEB Proxy" - before any actual
saved server, and that only grows once the WEB proxy catalog also
starts listing rows in the same section. Replaced the three with a
single "Add Proxy" row that presents an action sheet to pick the type,
matching the single-entry-point pattern upstream Telegram uses while
still exposing all three connection types this fork added.

The "Use proxy" toggle's implicit "no servers yet, tapping this adds
one" behavior forced SOCKS5 specifically (arguments.addNewServer(.socks5)),
inconsistent with the fact three types now exist. It now opens the same
type-picker menu as the dedicated Add Proxy row.

Also reviewed this whole screen and its ProxyServerSettingsController
counterpart line by line for correctness bugs beyond the redesign - the
issues found there (undefined `value` reference, optional/non-optional
secret mismatch, fail-open proxy configure, duplicate catalog stableId,
unconditional catalog fetch, unguarded Bag/MTContext enumeration, unused
BUILD dep) were already fixed in earlier commits on this branch; no new
ones turned up in this pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015LD9rT1B8mc4YRgymdiKmu

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
- Fail-closed: unreachable loopback when sidecar fails on first enable
- Serialize stop() with startLock to avoid sidecar resurrection race
- Always fetch WEB catalog (not gated on Auto MTProxy toggle)
- Hide :443 suffix and ping row for WEB proxies in list/preview
- Conference declined calls show missed icon

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
- WebProxyManager: non-blocking configure with generation-tracked async start
- registerWebProxySidecarReapply: network re-applies settings when sidecar
  becomes ready, fails to start, or stops at runtime
- Remove WEB proxy catalog fetch/UI; proxies are added manually only
- Localize WEB proxy strings via SocksProxySetup.ProxyWeb / MaskingSite

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
- Multi-stop radial disc with specular highlight, rim light, vignette
- Metallic annulus ring with sheen and shadow
- Layered plane: shadow, body, fold facet, wing highlight, crease line
- Supersampled downscale for crisp small sizes
- Enhanced Icon Composer SVG layers (Plane/Oval/Ring gradients)
- Regenerate all 20 alternate icon PNG sets

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Cartoon soldier launching a paper plane with Russian flag background.
Available in Settings → Customization → App Icon as «Патриот» / Patriot.

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Regenerate PatriotPlaneIcon with higher detail: tactical plate carrier,
ushanka with eagle emblem, sleeve chevron (tricolor + РФ), paper plane
launch, Russian flag backdrop. Add missing TelegramUIPreferences import
for ForkPresentationLanguage label.

Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Co-authored-by: D3C0Y <decoder-dev@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant