notes/win32-xaml-islands.md
Written 2026-08-21. Companion to net10-benefits-and-winui3.md, which assessed .NET 10 and
UWP → WinUI 3 as the only two options. This note assesses a third: keep Windows.UI.Xaml,
but move the process out of the UWP container, hosting the existing XAML in a
DesktopWindowXamlSource inside a Win32 .exe.
Everything stated as fact was read out of this repository or out of installed packages, and the source is named. Anything not verified locally is marked unverified.
The objection that killed this before — "islands mean losing .NET Native" — is gone: .NET 10
NativeAOT compiles a Win32 desktop app fine, and Telegram.Stub already is a
net10.0-windows10.0.18362.0 process. What is left is a genuinely different trade from the
WinUI 3 one, and on the evidence below it is the better one:
ApplicationView, CoreApplication,
Window.Current have to go either way. That cost does not distinguish the options and should
be struck from the comparison.XamlDirect, which net10-benefits-and-winui3.md identifies as the one verified hard
blocker for WinUI 3 (72 references, still, after the MessageTextBlock split). On this path
that blocker does not exist.Microsoft.UI.Xaml islands and Windows.UI.Xaml islands. So the migration that cannot be
done incrementally from UWP can be done incrementally from Win32, one surface at a time,
whenever WinUI 3 is worth having. Or never.The gate that decides the whole thing — does C# + NativeAOT + UWP XAML islands actually
work? — has been spiked, and it passes outright. Compiled markup, custom types resolved from
markup, merged resource dictionaries, visual states, XamlDirect, Composition, input, popups
that escape the window, and many windows on one thread — all of it, in a single ~4.8 MB
native exe. Every gate the spike defines passes except 1.7, which was never a capability
question.
Three things that had been listed as risks turned out not to be:
VoipCallCoordinator — a UWP workaround the app already degrades without.ShouldConstrainToRootBounds = false escapes the island fine.The remaining cost of this path is app-model work, not XAML work — and that is work the WinUI 3 path charges for too. Phase 0 is where it lives, and Phase 0 is worth starting now regardless of which destination is eventually chosen.
Not "migrate to Win32". Add a Win32 host beside the UWP one, from one source tree, and decide later — possibly never — whether UWP retires. Everything below is written against that.
This is far more reasonable here than on any other path, for one reason: the XAML layer is
100% shared. UWP and an island host both run Windows.UI.Xaml. Same 475 .xaml files, same
2381 usings, same XamlDirect, same Composition, same custom controls, same C++/WinRT
components. Nothing forks.
Compare the alternative. Dual-targeting UWP + WinUI 3 would fork 931 of 1259 files — every
namespace, XamlDirect vs no XamlDirect, two control sets. Nobody would attempt it. So
"keep both alive" is only an option because the XAML host is unchanged.
What actually has to exist twice is the app model, and that is exactly the Phase 0 surface
already inventoried: ~50 ApplicationView sites, 23 GetForCurrentView singletons, ~45
lifecycle handlers, the pickers and file access, InputListener, and ViewService. Call it
150 sites. Behind interfaces they mostly already have (IViewService, WindowContext), a
second implementation is additive, not a fork.
Four things make it cheaper than it sounds:
DISABLE_XAML_GENERATED_MAIN selects between
the XAML compiler's Application.Start Main and a hand-written one. One define, not an #if
scattered through the app.Telegram.csproj and Telegram.Modern.csproj already build the same
1259 files two ways.UseUwp=true does not imply the UWP app model (spike, finding 2). The same property that
gives the UWP project its projections gives the Win32 project its XAML toolchain.WINAPI_FAMILY_APP restricts which
APIs Telegram.Native may call; it does not stop the resulting DLL loading in a desktop
process. So the UWP-safe build likely serves both, and Phase 2.3 becomes deferrable until
something actually wants a desktop-only API. Unverified — worth an hour before planning
around it.The costs, honestly:
net10-benefits-and-winui3.md already names about #if
guards. The mitigation is that this seam is interface-shaped rather than #if-shaped, and
that item 0.9's analyzer is what keeps new code from reaching past it. That makes 0.9 more
important under this goal, not less.What it changes in the plan below: Phase 0 stops being preparation and becomes the deliverable — it is the abstraction layer that lets both hosts exist. Phase 2 items become "add a second implementation" rather than "replace", and 2.4 (dropping manifest capabilities) applies only to the Win32 flavour. Phase 3 is unaffected.
| then | now | |
|---|---|---|
| AOT | islands ⇒ no .NET Native ⇒ no AOT | NativeAOT works in a plain Win32 exe |
| Precedent | none in C# | still none in C# — Terminal is C++/WinRT (see below) |
| Projection | Windows.UI.Xaml stripped from desktop projections | Microsoft.Windows.UI.Xaml.dll ships the hosting API — verified, below |
| Win32 half | none | Telegram.Stub already exists, already .NET 10, already owns tray + loopback + passkeys |
Terminal is a Win32 app hosting Windows.UI.Xaml through WindowsXamlManager /
DesktopWindowXamlSource, shipping to millions. That settles "is the host alive" — it is, and
Terminal is why it stays maintained.
It settles nothing about a C# app. Terminal is C++/WinRT and supplies its own
IXamlMetadataProvider; the Microsoft.Toolkit.Win32.UI.XamlHost wrappers that made this work
from .NET were archived.
NonClientIslandWindow.cpp. Worth copying, because it is a bounded recipe rather than
open-ended work, and it is the thing UWP cannot do at all:
Initialize builds a
_rootGrid with two rows — TitlebarControl in row 0, client content in row 1._OnNcCalcSize applies the default frame via DefWindowProc, then restores the original
top so the standard caption is gone while the resize borders survive. When maximized (not
fullscreen) it adds the resize-handle height back: newSize.top += _GetResizeHandleHeight();._OnNcHitTest delegates the frame to DefWindowProc and handles only the top border and
the drag bar. _GetDragAreaRect transforms the XAML drag bar's bounds to client pixels._dragBarWindow over the caption buttons whose
_dragBarNcHitTest returns HTMAXBUTTON — which is what makes Windows 11 show the
snap-layouts flyout on hover (GH#9443). _InputSinkMessageHandler then handles
WM_NCLBUTTONUP for that region and calls _titlebar.ClickButton().So the answer to "can a custom caption still maximize and snap" is yes, and the mechanism is known. What Terminal does not give us for free is the multi-window story: theirs is a bespoke monarch/peasant protocol with a thread per window — but see gate 1.8a, which shows that part is not actually forced on us.
Two ways Terminal's case does not transfer, in opposite directions: its XAML surface is small (the terminal itself is D2D on a swapchain), and it started as Win32, so islands were pure upside. We would be running the trade the other way — paying to leave an app model.
Telegram.Stub and the FullTrustProcessLauncher dance. The manifest currently
ships a whole second full-trust process for a tray icon and a loopback exemption —
windows.fullTrustProcess pointing at Telegram.Stub.exe with /SystemTray and
/LoopbackExempt parameter groups. Tray, passkeys and the app service between them all
collapse into the main process.Suspending + 16 Resuming handlers and an
ExtendedExecutionSession that has to be requested and can be revoked, all replaced by "the
process runs until it exits". rescap:confirmAppClose and EnablePrelaunch go with them.FutureAccessList sites become paths. picturesLibrary /
removableStorage brokering goes away, and so does the per-call broker IPC on media writes.oneProcessVoIP rescap,
ordinary camera/mic enumeration — which is where the recording rework hit the privacy-indicator
wall.WINAPI_FAMILY_APP subset. ffmpeg, webrtc,
vlc, tdlib build as stock desktop libraries — directly relevant to the build-reproducibility
work and to duplicated-libraries.md.XamlDirect survives, and with it FormattedTextBlock's recycle path and
NativeUtils::AddRunToCollection. This is the single biggest difference from WinUI 3.Windows.UI.Xaml usings, 461
Windows.UI.Composition, 337 ElementCompositionPreview, 332 muxc: control uses, all 475
.xaml files, both C++/WinRT components, and RLottie.UWP in its own repo. None of it is
touched.net10-benefits-and-winui3.md
had counted as a WinUI-3-only benefit. It is not: thread-per-view came from the app model, not
from Windows.UI.Xaml.No C# precedent, and the supported .NET wrapper is archived. Resolved by the spike.
Hosting, input, compiled markup, custom types from markup, resources, visual states,
XamlDirect, Composition and NativeAOT all work from C# without the archived toolkit
wrappers. The residual cost is build plumbing — the XBF resource layout — not capability.
Windows.UI.Xaml is frozen. Choosing this is choosing to stay on a framework that gets no
new controls and no fixes. Defensible — the app is styled against WinUI 2.8 already and
controls everything above the framework — but it is a deliberate bet, and it is the same bet
as "stay on UWP", just with a better process around it.
The app-model rewrite is real work — but it is not a con relative to WinUI 3, which needs the same work. See Phase 0 for the surface.
VoipCallCoordinator — downgraded to a non-issue after reading the call sites. It was
listed here as the one question that could sink Win32. It is not, and the reason is that the
integration is itself a UWP workaround:
ReserveCallResourcesAsync (Extensions.cs:752), which is the UWP
background-execution reservation — the exact mechanism a Win32 process makes unnecessary.
InitializeSystemCallAsync gates everything else on it succeeding, so the whole block
exists to buy something a desktop process has for free.ApiInfo.IsVoipSupported is a runtime
ApiInformation.IsApiContractPresent("…CallsVoipContract", 1) check, every call is wrapped
in Try… helpers that swallow exceptions, and InitializeSystemCallAsync's catch nulls
_coordinator and _systemCall and carries on. Calls work with _systemCall == null —
that is a supported, already-shipping path, and WatchDog.TrackEvent("VoipCall", …) is
already measuring how often it is taken.RequestNewOutgoingCall and RequestNewIncomingCall
carry // TODO: this moves the focus from the call window to the main window :(. Dropping
the coordinator fixes that rather than costing anything.What is genuinely lost is small and replaceable: MuteStateChanged (hardware/shell mute →
AudioState), which becomes WM_APPCOMMAND / APPCOMMAND_MICROPHONE_MUTE_TOGGLE and then
works regardless of contract; and AnswerRequested/RejectRequested, which the app's own
call window and toast already provide. Three manifest entries go with it — the
windows.voipCall extension, uap:Capability voipCall, and rescap:oneProcessVoIP, the last
of which exists purely because UWP wanted the call in a separate background process.
CompactOverlay is lost. 24 sites; ApplicationViewSwitcher.TryShowAsViewModeAsync has no
desktop equivalent. Replaceable with an always-on-top sized window; the gallery PiP and the
call window are the users.
You inherit some of Terminal's chores: non-client hit-testing by hand (bounded — their recipe is above), per-window HWND lifetime, focus and accelerator routing across the island boundary, IME. Not their multi-window architecture, though — gate 1.8a.
Flyout/popup clipping. Tested and not a problem. A Popup with
ShouldConstrainToRootBounds = false escapes the island's HWND and renders over the desktop,
so the six sites that rely on it — ChatTextBox:719, CaptionTextBox:319,
FormattedTextBox:102, ChatRecordBar.xaml:149, EmojiMenuFlyout:329,
MessageEffectMenuFlyout:258 — keep working.
One trap for whoever reads the spike: a default MenuFlyout at the bottom edge opens
upward, which looks like clipping and is not. FlyoutBase.ShouldConstrainToRootBounds
defaults to true, so that is ordinary placement and happens identically in the UWP app
today. The spike now shows both variants side by side to keep the two apart.
ApplicationData.Current (49 sites) keeps working in a packaged desktop app. So do toasts,
share target, file type associations, protocol activation, startup task and the Store listing —
MSIX-packaged desktop apps get all of it. Distribution is not at risk on either path.
Settings are now the exception that no longer needs the app container at all. As of
2026-08-22 they sit behind ISettingsStore
(Telegram/Services/Settings/SettingsStore.cs), and ApplicationData.Current.LocalSettings is
referenced from exactly one live line in the whole app —
ApplicationDataSettingsStore.Local. Everything else goes through the two entry points,
AppSettings and ISettingsService. An unpackaged host therefore needs one new ISettingsStore
implementation and one line changed, rather than touching ~200 accessors. The remaining 45
ApplicationData.Current sites are LocalFolder (35) and TemporaryFolder (10) — file access,
which 0.7 covers and which is fine on desktop regardless.
The seam is not free of work: the interface has a Flush() that ApplicationDataSettingsStore
implements as a no-op, because ApplicationData persists as it goes. Any file-backed store has
to make that real, and needs a call from suspend and close. See
notes/settings-service-refactor.md §4.1 and step 6.
Spiked 2026-08-21 in C:\Source\XamlIslandSpike. A C# desktop process on .NET 10 hosts UWP
XAML in a DesktopWindowXamlSource, and XamlDirect works inside it. All seven gates the
spike covers pass:
PASS 1.2a DispatcherQueue on thread - controller 0x1da84ca0a10
PASS 1.2b WindowsXamlManager.InitializeForCurrentThread- ok
PASS 1.2c CreateWindowEx host - hwnd 0x301136
PASS 1.2d DesktopWindowXamlSource.AttachToWindow - island hwnd 0x19009c6 (Native2)
PASS 1.2e Stock XAML content - content set
PASS 1.4 XamlDirect inside the island - inline count 1
PASS 1.5 ElementCompositionPreview + animation - compositor Compositor
TIME process start -> island ready - 84 ms
That run is the NativeAOT build — gate 1.6 passes too, and it is the same seven passes as the JIT build, not a reduced set. Measured over four runs each, same machine, cold-ish:
| NativeAOT | JIT (self-contained) | |
|---|---|---|
| startup, process start → island ready | 75 / 78 / 79 / 82 ms | 203 / 205 / 224 / 229 ms |
| publish output | 1 file, 4.0 MB | 196 files, 122.4 MB |
| gates passing | 7 of 7 | 7 of 7 |
So UWP XAML, XamlDirect, Composition and the island host all survive ILC, in a single
self-contained 4 MB exe with no CsWinRT or runtime assemblies beside it. Nothing about AOT is a
compromise on this path — it is ~2.7× the startup and 1/30th the layout.
Two build notes for whoever repeats this:
BuiltInComInteropSupport must be false. NativeAOT does not support built-in COM
interop, which is precisely why IslandNative does manual QueryInterface + vtable function
pointers instead of a [ComImport] cast. The workaround forced by finding 4 below is what
makes AOT possible — the two are the same decision.vswhere on PATH. Without it the link step fails with a mangled command line
(MSB3073 ... exited with code 123) that looks like an AOT failure and is not. Prepend
C:\Program Files (x86)\Microsoft Visual Studio\Installer, or publish from a developer prompt.The make-or-break. A UserControl with compiled markup, a StaticResource, a custom C# type
(local:Badge) named from markup, and a VisualStateManager — all resolved through the
generated XamlTypeInfo.g.cs provider, in the island, under NativeAOT:
PASS 1.3a new App() - generated IXamlMetadataProvider - Application + IXamlMetadataProvider
PASS 1.3b Compiled markup (InitializeComponent + StaticResource)
PASS 1.3c Custom C# type resolved from markup (local:Badge) - Badge.Level = 'ok'
PASS 1.3d VisualStateManager.GoToState from markup states - went to Highlighted
11 of 11 gates, zero failures across four consecutive AOT runs, 227–272 ms to island ready.
Package identity is not required — ms-appx:/// resolves against the exe directory
unpackaged, so the earlier suspicion was wrong; it was purely a file-layout problem.
Five things the real migration has to know:
UseUwpTools=true and UseUwp=true are required, and it must be built with VS
MSBuild. UseUwpTools is what imports the XAML compiler
(Microsoft.Windows.UI.Xaml.CSharp.ModernNET.ImportAfter.targets), but the targets refuse to
run without UseUwp, which supplies the projections. The imports live in VS's
ImportBefore/ImportAfter hooks, so the dotnet CLI's MSBuild cannot build this — same
constraint Telegram.Modern.csproj already documents.UseUwp=true does not force the UWP app model. This is the important one: the output is
still an ordinary Win32 Exe with its own Main and message loop. UseUwp buys the
projections and the XAML toolchain, not AppContainer.DISABLE_XAML_GENERATED_MAIN is mandatory. The XAML compiler emits its own
Program.Main calling Application.Start — the UWP entry point. An island host owns its
message loop, so that Main has to be suppressed. This define is precisely the seam between
the two app models.ApplicationDefinition (App.xaml) is required, and is where the generated
IXamlMetadataProvider lands. Without one the XAML compiler dies with an internal error
(WMC9999: Object reference not set to an instance of an object), which is not a useful
diagnostic — remember it.InitializeComponent loads
ms-appx:///XamlIslandSpike/SpikeCard.xaml — the assembly-name-prefixed library form —
while the build drops the .xbf at the output root, and Publish omits the .xbf files
entirely (the AOT publish folder held only the exe and pdb). Both are build plumbing, not
capability limits, but both fail at runtime as an opaque XamlParseException. Budget time
for getting the resource layout right across 475 .xaml files.PASS 1.2c-e Win32 host + DesktopWindowXamlSource + content - hwnd 0x1090d90
PASS 1.8a Second island on the SAME thread - islands on this thread: 2
Two HWNDs, two DesktopWindowXamlSources, one WindowsXamlManager, both hosting compiled
markup, no CoreApplication.CreateNewView() and no per-view dispatcher anywhere.
This matters more than it looks. net10-benefits-and-winui3.md lists "WinUI 3 supports multiple
windows on one thread, which would delete the entire class of bug this repo has been fighting —
CsWinRT #2524, and the still-open access violation where a secondary view's RCWs are released
after its XAML core is gone" as one of the few genuine arguments for WinUI 3. That argument
applies to this path too. The thread-per-view model came from the UWP app model, not from
Windows.UI.Xaml, so leaving the app model is what removes it — the XAML framework was never
the reason.
So ViewService's rewrite (Phase 0, item 0.4) is not merely a port: on either path it can
collapse to plain HWNDs on the UI thread.
With this, nothing about the XAML layer is unproven any more. Hosting, input, markup,
custom types, resources, visual states, XamlDirect, Composition and NativeAOT all work
together from C#. What remains open is app-model surface, not XAML.
Four further findings worth carrying forward:
UseUwp, and no VS MSBuild. A plain net10.0-windows10.0.26100.0 Exe with a direct
<Reference> to Microsoft.Windows.UI.Xaml.dll out of the ref pack compiles and runs under
the ordinary dotnet build. The projection is not gated to the UWP app model.maxversiontested in a side-by-side manifest. Without it
WindowsXamlManager.InitializeForCurrentThread() fails with E_UNEXPECTED
("Catastrophic failure") — there is no package manifest to carry the declaration. This was the
only hard failure hit, and the error message names the fix.DispatcherQueueController must exist on the thread first (CreateDispatcherQueueController
from coremessaging.dll, DQTYPE_THREAD_CURRENT). CoreWindow used to supply this.[ComImport] casts do not work on CsWinRT-projected objects.
(IDesktopWindowXamlSourceNative2)(object)source throws InvalidCastException — unlike the
(ICoreWindowInterop)(object)window pattern in WindowContext.cs, which works because that
object comes from the UWP projection. The fix is WinRT.MarshalInspectable<T>.FromManaged,
a manual QueryInterface, and vtable calls through function pointers. That is more code but
it is also NativeAOT-clean, which gate 1.6 wants anyway. IslandNative.cs in the spike is the
reusable version.XamlDirect passing is the headline. It is the one verified hard blocker for WinUI 3, and
it does not exist on this path — FormattedTextBlock's recycle queues and
NativeUtils::AddRunToCollection would move across unchanged.
Every gate now passes, 1.7 included — the spike has no open questions left. The custom non-client caption works: the window has no system caption at all, XAML reaches the top edge, and dragging, double-click maximize, the system menu, the top resize border, the three caption buttons and the Windows 11 snap layouts flyout all behave.
The one part that is not free is snap layouts, and Terminal marks it // BODGY: the island's
core input site covers the whole window and steals WM_NCHITTEST, so returning HTMAXBUTTON
where the maximize button is requires a separate WS_EX_LAYERED | WS_EX_NOREDIRECTIONBITMAP
drag-bar HWND laid over the buttons, which then forwards hover and press back to the XAML ones by
hand (_InputSinkMessageHandler, plus TRACKMOUSEEVENT bookkeeping).
And there is no downside case: UWP with a custom caption cannot maximize at all — measured, see the custom title bar spike — so the Win32 host is strictly better than what ships today even if snap layouts were skipped entirely.
Microsoft.Windows.SDK.NET.Ref 10.0.26100.57 ships
Microsoft.Windows.UI.Xaml.dll (7.3 MB) as a separate assembly from
Microsoft.Windows.SDK.NET.dll, and it contains the hosting API:
| type | in projection |
|---|---|
Windows.UI.Xaml.Hosting namespace | yes (102 metadata hits) |
WindowsXamlManager | yes |
DesktopWindowXamlSource | yes |
XamlSourceFocusNavigationRequest | yes |
Windows.UI.Xaml.Core.Direct / XamlDirect | yes |
So the projection surface exists and is reachable. What is still unknown, in order of how likely it is to end the discussion:
Microsoft.Windows.UI.Xaml.dll be referenced from a desktop TFM, or does UseUwp
gate it to the UWP app model? (UseUwp is not in the .NET SDK — it comes from VS's
ImportBefore/ImportAfter hooks, per the comment in Telegram.Modern.csproj.)WindowsXamlManager.InitializeForCurrentThread() succeed in a desktop process, called
from C#?IXamlMetadataProvider — the generated XamlTypeInfo.g.cs — be registered so
custom controls and ResourceDictionary merges resolve? This is the archived-wrapper
question and the one most likely to fail.XamlDirect and ElementCompositionPreview work inside an island?C:\Source\XamlIslandSpike answers these. See the todo.
Where it stands, 2026-08-23. Window.Current 73 -> 22 mentions, of which 5 are comments and
most of the rest are the seam itself (WindowContext, BootStrapper) or 0.4's rewrite.
WindowContext.Current 124 -> 42, and the distribution is now lopsided in a useful way:
BootStrapper (14) and App.xaml.cs (6) are bootstrap code that forks anyway, ViewService (3)
is 0.4, and the remaining ~15 are ordinary UI sites in four property families — ActualTheme,
RasterizationScale, Bounds/PointerPosition, and NavigationServices/Content/Title.
How to reach the window, in order of preference (Fela's rule, and it corrects an earlier
draft of this paragraph that reached for ForXamlRoot first):
ViewModel.Window, or NavigationService.Window — whenever a view model or a navigation
service is in reach. It does not depend on the element being in the tree, and it says where
the window came from.WindowContext or Theme, resolved once at a hook where the element is attached
and held in a field — what MessageBubble does at Loading.WindowContext.ForXamlRoot(...) — last resort, for a leaf control with neither.The reason is not taste. ForXamlRoot resolves through XamlRoot, which is null until the
element is attached (0.17), so it is a trap in OnNavigatedTo, in constructors and anywhere
before Loading — three of the theme update sites were converted to it and two had to be
changed again. It also costs a lookup per call and hides which window is meant. Theme.Current 50 -> 25, of which 15 are the MonospaceFontFamily
/ XamlAutoFontFamily pair still waiting on 0.19a.
UI [ThreadStatic] is down to Theme (9), WatchDog (5, diagnostics), ProfileCell (4),
MessageBubbleBrush (1, dead code) and Direct2D (1); PlaceholderHelper's is gone. The
per-XamlRoot ConditionalWeakTable pattern is now applied across OverlayWindow,
AnimatedImage, ProfilePicture, ContentPopup and RelativeDateService, and
DispatcherContext.Current replaced the WindowContext.Current.Dispatcher hops.
Everything here is worth doing on its own merits, ships incrementally on develop, needs no
decision about the destination, and pays off identically for UWP-forever, islands, or WinUI 3.
Start here regardless of what the spike says.
The good news from the survey: WindowContext is already the funnel — 18 of the 50
ApplicationView sites are in that one file, it already exposes Handle via
ICoreWindowInterop, and it already drives a DispatcherContext off DispatcherQueue rather
than CoreDispatcher. Most of this phase is finishing a job that is well started.
0.1 Dispatchers — much narrower than it first looked. Two things measured in the spike (gate 1.9) change the shape of this:
FrameworkElement.Dispatcher still works inside an island — it returns a live
CoreDispatcher, not null. So the ~20 sites doing SomeElement.Dispatcher.RunAsync(...)
(ReactionButton, ChartCell, ContentPopup, GroupCallMessageCell, SendFilesPopup,
Extensions, …) keep working unchanged on this path. Do not sweep them.DependencyObject has no
DispatcherQueue property — it does not compile. That property is a WinUI 3 addition.So this is not a 26-site mechanical conversion. What actually needs work is only the handful that reach a dispatcher through types that do disappear:
Navigation/InputListener.cs — done 2026-08-24, and the advice here was wrong.
It said to rebuild on the island root's KeyDown/PreviewKeyDown or the host's message
loop. Routed events are not an option, for two reasons that only turned up on contact:
AcceleratorKeyActivated distinguishes SystemKeyDown, and the handler depends on it —
Alt+Left and Alt+Back go through VirtualKeyModifiers.Menu.RichEditBox swallows, and
keys pressed while focus is in a WebView2, which is not in the XAML tree at all.Fela had already tried the routed-event route once, without success, which is very likely the same wall. So: the message loop, and a fork of the class.
The shape matters as much as the choice. The first attempt raised app-owned events off
WindowContext and fed them per host — which reads well, but moved the CoreWindow
subscriptions in the shipping app, changed IShortcutsService.Process, and put an args
allocation on every keystroke. Fela: "I would prefer to keep UWP as it is as it ships to
millions", and "if you have to fire a new event try to cache the args instead of recreating
per key stroke/mouse click". Forking removes the question entirely — no event, no args:
InputListener.cs became InputListener.Uwp.cs, content byte-identical (git reports a
pure rename); only its Telegram.csproj entry changed.InputListener.Win32.cs implements IMessageFilter, which IslandWindow consults ahead
of its own accelerator handling. WM_KEYDOWN/WM_SYSKEYDOWN carry the virtual key in
wParam; WM_XBUTTONDOWN carries the button chord in its low word, so the back/forward
gesture reads it directly with no PointerPoint.ShortcutsService gained a Process(VirtualKey, out modifiers) overload rather than a
changed signature, so the UWP call site is untouched.The decision logic is a deliberate copy, not a shared base: the two halves differ only in where the key comes from, and the UWP one ships. About forty lines, and the rule that bought it — an edit to a shipping path needs a better reason than symmetry — is worth keeping for the rest of the fork.
Views/InstantPage.xaml.cs:91 — a second AcceleratorKeyActivated handler, same treatment.
Services/ViewService/ViewService.cs:118 — CoreApplication.MainView.Dispatcher.RunAsync;
goes with CoreApplication in 0.4.
Services/ViewService/ViewLifetimeControl.cs:232 — Window.Dispatcher.RunAsync; same.
The sweep only becomes necessary for Phase 3, where DependencyObject.Dispatcher returns null
and DispatcherQueue replaces it. Worth knowing, not worth pre-paying.
0.2 Seal WindowContext — no Window, no CoreWindow leaking out. Three members
publish the UWP types to the rest of the app:
public static implicit operator Window(WindowContext) — the widest hole; find its users
and give them named members instead.public CoreWindow CoreWindow => _window.CoreWindowpublic CoreWindowActivationMode ActivationMode — return an app-owned enum.After this, Navigation/WindowContext.cs is the only file in the app that names either type.
0.3 Move the ApplicationView surface onto WindowContext members. The set actually
used is small and every one has a Win32 answer, so the shim is thin:
| used today | sites | desktop equivalent |
|---|---|---|
Title | 3 | SetWindowText |
TitleBar (via CoreApplication.GetCurrentView()) | 3 | custom caption + WM_NCHITTEST |
Consolidated | 5 | WM_CLOSE / window destroy |
VisibleBoundsChanged | 3 | WM_SIZE / WM_DPICHANGED |
TryEnterFullScreenMode / ExitFullScreenMode / IsFullScreenMode | 7 | style swap + SetWindowPos |
IsScreenCaptureEnabled | 2 | SetWindowDisplayAffinity |
TryResizeView / SetPreferredMinSize | 2 | SetWindowPos / WM_GETMINMAXINFO |
PersistedStateId | 1 | settings |
TryConsolidateAsync / Id | 4 | window close / HWND |
IsViewModeSupported | 1 | drop with CompactOverlay |
0.4 Split ViewService, and leave ViewLifetimeControl alone. Fela's design, and it
is far smaller than this item has looked all along, because ViewLifetimeControl does not
actually leak — measured 2026-08-24:
VoipCall.cs:756 and VoipGroupCall.cs:2051 are
_ = service.OpenAsync(options);. So does every await OpenAsync(...) in
TLNavigationService. No caller anywhere reads the returned control.Content callback ignores the parameter: the two CreatePresentation methods and the
seven (control, window) => lambdas in TLNavigationService all read only window.So the whole leak was three signatures.
Step 1 — behaviour-preserving, done 2026-08-24.
Func<ViewLifetimeControl, WindowContext, UIElement> Content -> Func<WindowContext, UIElement>
Task<ViewLifetimeControl> OpenAsync(...) -> Task<WindowContext>
plus the two NavigationService forwarders, the seven lambdas and the two
CreatePresentation bodies. IViewService now names no UWP type, and
ViewLifetimeControl is confined to its own folder — the only mentions left outside it are
in SecondaryViewSynchronizationContextDecorator, which is UWP-only by nature.
Three things fell out that were not in the plan:
WindowContext.Id is the view id (GetApplicationViewIdForWindow, same as
ViewLifetimeControl.Id), so TryShowAsViewModeAsync/TryShowAsStandaloneAsync/
SwitchAsync take it unchanged.OpenAsyncInternal called ViewLifetimeControl.GetForCurrentView() purely to have
something to return. Dropping it is safe: GetOrAdd means the control is already created
by ViewService.OnWindowCreated, which must have run for WindowContext.Current to be
valid in the same callback.oldControl = ViewLifetimeControl.GetForCurrentView()
inside a ForEachAsync body — correct only because the body is dispatched to that window's
thread. It is now just oldWindow = window, which is what it always meant.ViewLifetimeControl.Facade() was left with no callers (FacadeAsync returns
WindowContext.Current now), so it went, and with it the ctor's newWindow == null
Xbox branch and the unused parameter. Step 2 — done 2026-08-24, and it is 100 lines. ViewService.cs keeps the interface,
the enum, the options and _mainWindowCreated; ViewService.Uwp.cs takes every path that
names CoreApplication.CreateNewView, ApplicationViewSwitcher or ViewLifetimeControl;
ViewService.Win32.cs makes another IslandWindow on the same thread and wraps it in a
WindowContext. Gate 1.8a is what makes that legitimate, and item 0.18 is why no secondary
window needs a thread of its own.
ViewLifetimeControl never moved, exactly as planned - it and
SecondaryViewSynchronizationContextDecorator were simply renamed .Uwp.cs so the Win32
project's **\*.Uwp.cs exclusion drops them. All 21 ApplicationView sites went with it and
were never ported.
Two things the Win32 half does differently, both deliberate:
ViewMode is ignored. CompactOverlay is a UWP app model feature with no Win32
equivalent; picture-in-picture becomes a small topmost window the app positions itself,
which is Tier 2 of the fork list rather than this item.WindowContext.Content rather than passed to
IslandWindow.Create, so a secondary window takes the same path as the main one - building
the WindowControl, merging the chat theme, publishing the XamlRoot.It also needed IsInMainView on the Win32 side, which nothing had set: UWP asks
CoreApplication which view it is in, and here the answer is whichever window came first.
The chat-already-open search depends on it.
This was described here as "the multi-month piece in every path". That was wrong: the mass is all inside the class that isn't moving.
0.5 Wrap the remaining GetForCurrentView singletons. The complete surveyed surface,
generated code excluded. ApplicationView is item 0.3; ViewLifetimeControl is our own type.
| type | sites | where | what is used | desktop replacement |
|---|---|---|---|---|
ConnectedAnimationService | 12 | Gallery, ProfileHeader, VoipPage, ChatView, EditMediaPopup, SendFilesPopup | PrepareToAnimate, GetAnimation, DefaultEasingFunction | survives; needs a per-window owner (0.5b) — same in WinUI 3 |
SystemNavigationManagerPreview | 8 | OverlayWindow, VoipPage, TextEditorRichPopup, WebAppPage | CloseRequested only | deleted — per-HWND WM_CLOSE |
ResourceContext | 6 (4 live, 2 commented) | MessageHelper, FrameFacade, SettingsLanguageViewModel | .Reset() after a language change | MRT Core ResourceManager |
SystemNavigationManager | 3 | BootStrapper, WindowContext | BackRequested, AppViewBackButtonVisibility | deleted — Xbox/tablet-mode legacy; keep only mouse-back via WM_APPCOMMAND |
CompositionCapabilities | 3 | PowerSavingPolicy, DiceView, DiagnosticsViewModel | AreEffectsFast() | works on desktop; GPU capability, not really per-view |
UIViewSettings | 1 | GalleryWindow:1079 | UserInteractionMode | IUIViewSettingsInterop::GetForWindow(hwnd) |
SystemMediaTransportControls | 1 | PlaybackService:230 | the whole transport | ISystemMediaTransportControlsInterop::GetForWindow(hwnd) |
DisplayInformation | 1 | MasterDetailView:843 | — | per-window display info / monitor APIs |
The shape of the work is one pattern, not eight. Most of these have a documented
…Interop::GetForWindow(HWND) sibling — IUIViewSettingsInterop,
ISystemMediaTransportControlsInterop, IDataTransferManagerInterop, IInputPaneInterop,
IPrintManagerInterop, plus IInitializeWithWindow for the pickers in 0.7. So 0.5 and 0.7
are the same job: get an HWND to the call site. WindowContext.Handle already exposes one
(via ICoreWindowInterop), which is why 0.2 should land first.
Not affected, and worth knowing so nobody "fixes" them:
DispatcherQueue.GetForCurrentThread() — 22 sites, works unchanged on desktop.UISettings — 11 references but constructed with new UISettings(), not per-view. Fine.DataTransferManager — only IsSupported() is used (ChooseChatsViewModel:509). If share
UI is ever invoked, that needs IDataTransferManagerInterop::ShowShareUIForWindow. 0.5b ConnectedAnimationService needs a per-window owner — the one real catch behind
1.8a. Gate 1.8c: with two islands on one thread,
ConnectedAnimationService.GetForCurrentView() returns the same instance for both, while
their XamlRoots differ. "Current view" in an island host means current thread.
With 12 sites keyed on plain strings — "FullScreenPicture" in the gallery,
"EditMediaPopup" in SendFilesPopup/EditMediaPopup — two windows running the same
animation key at once would collide. Today each view has its own thread, so it cannot happen.
This is not a cost of this path. ConnectedAnimationService.GetForCurrentView() behaves
the same way in WinUI 3 (unverified locally — WinAppSDK is no longer in the package cache;
asserted by Fela and consistent with WinUI 3's one-thread-many-windows model). It is a
property of putting many windows on one thread, which is the goal on both paths. Handle it
when 0.4 lands; it is 12 sites and two string keys.
The other two singletons listed here originally. Both were me over-applying 1.8c to APIs
that do not survive the move at all:
SystemNavigationManagerPreview.CloseRequested (8 sites) is not re-keyed, it is
deleted. It exists because UWP will not let an app intercept window close — that is what
rescap:confirmAppClose is for. In Win32 every HWND gets its own WM_CLOSE, which is
inherently per-window, so the sharing problem cannot arise.SystemNavigationManager (3 sites) is deletion too, and the code already says so:
BootStrapper.cs:83 is commented // WARNING: this is used by Xbox (and some Windows users), and WindowContext.cs:178 sets AppViewBackButtonVisibility = Collapsed from
inside a #region Legacy code. It was for Xbox and Windows 10 tablet mode; Xbox support is
gone and Windows 11 has no tablet mode. The only piece worth keeping is the mouse back
button, which becomes WM_APPCOMMAND / APPCOMMAND_BROWSER_BACKWARD — more reliable than
what it replaces. 0.6 Narrow the CoreWindow uses. 44 sites, mostly input.
PointerCursor — 24 sites, done. WindowContext.SetPointerCursor(PointerCursorType)
with an app-owned enum and one cached CoreCursor per type. Removed the per-call
new CoreCursor(...) allocation — FormattedTextBlock was doing it on pointer move —
and deleted five hand-rolled cursor caches in ImageTextSelection and
MasterDetailPanel. Five files no longer import Windows.UI.Core at all. Builds.
CharacterReceived — 10 sites, done. Replaced with the routed
UIElement.CharacterReceived, which works in an island because it is XAML input rather than
CoreWindow. Three shapes, because the sites were doing three different things:
FormattedTextBox (its own emoticon replacement) subscribes to itself, via
AddHandler(..., handledEventsToo: true) — a plain += never fires, because RichEditBox
consumes the character and marks the event handled. Killed the _characterReceived
double-subscription guard and both Loaded/Unloaded handlers with it: they existed only
because CoreWindow outlives the control.MainPage / ChatView (type-to-search, type-to-compose) go through a new
WindowContext.CharacterReceived, raised from the window's root element with a plain +=.ChooseChatsPopup / SendFilesPopup subscribe to themselves — a popup is its own
subtree, so anything focused above it never bubbles down.What made it worth doing beyond the port: every FocusManagerEx.TryGetFocusedElement and
GetOpenPopupsForXamlRoot check was hand-rolling what routed events already encode. A
focused TextBox/RichEditBox marks the character handled, and a popup only swallows it
when it took focus — which is exactly what ChatView's hardcoded allow-list
(ToolTip, TeachingTipRootGrid, ReactionAnimation, empty Grid) was enumerating: the
popups that do not take focus. All of it deleted. Also gone: two allocations per keystroke
(Encoding.UTF32.GetString(BitConverter.GetBytes(args.KeyCode))) in each of four handlers
that were all subscribed to the same CoreWindow, so every keystroke anywhere paid it 4x.
The trap, found by testing. Two shortcuts used to ride in on the character stream,
because CoreWindow.CharacterReceived sat below XAML's key handling. The routed event sits
above it, so they behave differently:
'\r' branch in both send popups went dead and Enter stopped closing them. Restored as an
explicit PreviewKeyDown handler calling Accept(), guarded by the same four focused
types. Preview rather than KeyDown because a focused ListViewItem sits in a ListView
that may consume Enter for item invocation, so bubbling never reaches the popup.Worth knowing that ContentPopup.OnProcessKeyboardAccelerators also maps Enter to
Hide(ContentDialogResult.Primary) with an identical focus check, and appears never to fire
for these two — the '\r' branch was the live path. Whatever suppresses it is unexplained;
do not assume the base class covers Enter for a ContentPopup.
Verified by hand: type-to-search, type-to-compose, emoticon replacement, Ctrl+V paste, and Enter from a focused list item closing the popup with the caption intact.
ActivationMode — 6 sites, done. No app-owned enum was needed: five of the six
asked != Deactivated, so they became WindowContext.IsActive. Only
ChatView.Window_Activated wants the three-way split, and it ignores the middle state, so
IsForeground plus !IsActive reproduces it exactly. That site was also reading
Window.Current while handling its own window's Activated event — wrong in a secondary
view; it now uses NavigationService.Window.
GetAsyncKeyState 2, PointerPressed 2, FlowDirection 2, PointerPosition 1,
ResizeStarted/ResizeCompleted 2 each.
0.10 WindowContext.Current is [ThreadStatic], and gate 1.8a makes that a problem.
92 sites. It resolves "the window on this thread", which is unambiguous only because UWP gives
every view its own thread. The moment many windows share one thread — which 1.8a proved we can
do, and 0.4 will do deliberately — Current silently returns whichever window was constructed
last on that thread.
This is not hypothetical for the migration; it is the same class of bug as CsWinRT #2524.
The fix already exists in the codebase: WindowContext.ForXamlRoot(...), used at 27 sites
today (MainPage.OnLoaded among them). Anything holding a UIElement can resolve its own
window instead of the thread's.
So the work is: audit the 92, convert the ones with an element or XamlRoot in reach, and
decide what Current should mean for the rest — probably "the active window", explicitly
tracked, rather than a thread-static. Worth doing before 0.4 rather than after, because 0.4 is
what makes the ambiguity real.
Two known callers to fix while doing it: AnimatedImage (3 sites, converted to
WindowContext.Current.IsActive in the ActivationMode change above — no worse than the
Window.Current it replaced, but no better either), and note that Current's getter logs
Environment.StackTrace when null, which is not something to leave on a hot path.
What the replacement cannot be. WindowContext already has three statics —
Current (thread-static), Active, and Main — and Active looks like the obvious answer
until you read how it is maintained (OnActivated):
if (e.WindowActivationState != CoreWindowActivationState.Deactivated) Active = this;
else if (Active == this) Active = null;
Active goes null whenever the app loses focus. Current never does while a window
exists on the thread. So it is not a drop-in: swapping the 124 sites onto Active would start
throwing the moment you alt-tab, which is precisely when background callers like
AnimatedImage ask. Any fix has to pick a defined fallback (Active ?? Main, or a
never-nulled "last active"), and that is a decision, not a rename.
Count went up before it goes down. The Window.Current migration deliberately funnelled
60-odd scattered sites into WindowContext.Current, taking it from 92 to 124. That is the
point: each Window.Current site needed its own judgement, whereas the 124 need one decision
applied consistently. Sequence it with 0.4 — 0.4 is what makes the ambiguity real, and 0.4 is
also what supplies the per-window plumbing the fix needs.
0.12 WindowContent — done. Fela's design: one base for everything assigned to
WindowContext.Content, with the window-level events behind overridable methods.
WindowEx (which lived at the bottom of VoipPage.xaml.cs, below 1350 lines of call UI)
became Telegram.Controls.WindowContent. Its constructor set white-on-transparent caption
buttons — call-window chrome that every root would have inherited — so that became an opt-in
UseDarkCaptionButtons().
All ten roots derive from it, where they previously had four different bases
(Page, UserControl, UserControlEx, WindowEx). RootWindow/StandaloneWindow were
Page but used no Page member — every .Frame was service.Frame.
IPopupHost is now OnPopupOpened/OnPopupClosed virtuals. Six of the seven roots were
doing the identical SetTitleBar(null) / SetTitleBar(<element>) pair, differing only in
which element they named, so that collapsed to protected override UIElement TitleBarElement.
Only RootWindow genuinely differs (it forwards to its IRootContentPage).
Activated, VisibilityChanged and CloseRequested are wired once, in
WindowContent.OnLoaded/OnUnloaded, and surfaced as
OnWindowActivated(bool) / OnWindowVisibilityChanged(bool) /
OnWindowCloseRequested(WindowCloseRequestedEventArgs). Every +=/-= pair for window
events now lives in one file — the CLAUDE.md pairing rule enforced structurally.
WindowCloseRequestedEventArgs wraps the UWP args and exposes only Handled and
GetDeferral(), the two things all four callers used, so
SystemNavigationCloseRequestedPreviewEventArgs no longer appears outside the base.
Window is required at construction, not resolved. It was briefly
ForXamlRoot(this) in a property getter, but XamlRoot is null until the content is in a
tree, which is later than some roots need it — Fela's call, and the right one. Every root is
created with new and never reparented, so protected WindowContent(WindowContext) is
honest about the dependency and impossible to get wrong.
This also deleted six duplicate WindowContext fields (_context/_window, 27 references)
that each root was keeping alongside the base's, and took the roots to zero
WindowContext.Current — app-wide it is now 94, down from a peak of 124.
Four roots had to gain the parameter: RichTextWindow and the three call windows. All four
already had a WindowContext in scope at their call site, because ViewServiceOptions.Content
and CreatePresentation were given one earlier — so nothing had to reach for a thread-static
to supply it.
Renamed and regrouped: nine *Page roots became *Window (they were never pages), and
PasscodeWindow, RichTextWindow (was TextEditorRichPopup) and WebAppWindow moved into
Views/Host beside the other four.
Do not create a Views/Windows folder — tested, and it does not compile. Folder maps to
namespace here, and inside namespace Telegram.Views.Windows the simple name Windows binds
to that namespace before the global WinRT root:
CS0234: The type or namespace name 'Foundation' does not exist in the namespace 'Probe.Views.Windows'. Every Windows.* reference would need global::.
0.13 Every WindowContext event carries app-owned args. Fela's rule: anything the
Win32 host has to raise, it has to be able to construct — and a UWP
VisibilityChangedEventArgs, WindowSizeChangedEventArgs or
ApplicationViewConsolidatedEventArgs cannot be newed. The five events now carry
PopupActivatedEventArgs, WindowActivatedEventArgs, WindowVisibilityEventArgs and
WindowSizeChangedEventArgs, all declared in Telegram.Navigation.
CharacterReceived is the deliberate exception: CharacterReceivedRoutedEventArgs is a XAML
routed type, forwarded from _content.CharacterReceived and never constructed by us. An
island raises it the same way, so it needs no wrapper.
The app types intentionally shadow the UWP ones inside Telegram.Navigation — which is why
BootStrapper.OnActivated, which really does handle the raw Window, has to qualify
Windows.UI.Core.WindowActivatedEventArgs. Anything left on the UWP args fails loudly.
0.3a ApplicationView events — into WindowContent. Consolidated and
VisibleBoundsChanged are wired by the base and surfaced as OnWindowConsolidated() /
OnWindowVisibleBoundsChanged() — no args, because the only root consuming them ignored
the Consolidated payload entirely and wanted IsFullScreenMode from the other, which
WindowContext already exposes. So ApplicationViewConsolidatedEventArgs never reaches a root.
WebAppWindow.OnLoaded became empty and was deleted; its doc comment — that UserControlEx
guarantees OnLoaded/OnUnloaded alternate where raw Loaded/Unloaded re-fire on
reparenting, and that a duplicate handler on a deferral-taking event means a duplicate
confirmation dialog — moved to WindowContent.OnLoaded, where that reasoning now lives.
0.3b ApplicationView methods — onto WindowContext. Done. New members:
VisibleBounds, TryResizeView(Size), SwitchToAsync(), and a static
PreferredLaunchViewSize (process-wide, not per-window). TryEnterFullScreenMode() now returns
bool — LiveStreamWindow only flips its button when it succeeds, and the old void signature
had silently dropped that.
Two things fell out rather than being converted:
TryConsolidateAsync calls were dead. They were else branches for Window == null,
which stopped being reachable once the constructor started requiring a WindowContext.RootWindow.OnVisibleBoundsChanged was dead, referenced only from commented-out code
above it. Deleted.After this, ApplicationView appears in the roots only in comments — plus
CoreApplicationViewTitleBar.LayoutMetricsChanged in StandaloneWindow/TabbedWindow, which
is a different API (CoreApplication.GetCurrentView().TitleBar, with IsVisibleChanged
alongside) and is left for its own pass.
Still excluded, still correct: ViewService + ViewLifetimeControl (21 of the 50 sites) are
0.4's rewrite, and OverlayWindow / GalleryWindow / ZoomableMediaPopup are not roots.
0.3c CoreApplicationViewTitleBar — StandaloneWindow and TabbedWindow each wire
IsVisibleChanged + LayoutMetricsChanged off CoreApplication.GetCurrentView().TitleBar and
handle both with one method. Same shape as everything else WindowContent absorbed, so it
belongs there as a virtual — and CoreApplicationViewTitleBar is another type an island host
has no equivalent for.
0.12 A The types that get assigned to WindowBase for the window rootsWindowContext.Content are window roots, not pages,
and they all hand-wire the same set of window-level events:
| wired by hand today | where |
|---|---|
SystemNavigationManagerPreview.CloseRequested | OverlayWindow, VoipPage, TextEditorRichPopup, WebAppPage — 8 sites, always a +=/-= pair |
WindowContext.Activated | WebAppPage, PasscodePage, TextEditorRichPopup, StoriesWindow, ChatView |
WindowContext.VisibilityChanged | ChatView and others |
ApplicationView.Consolidated / VisibleBoundsChanged | WindowContext, GalleryWindow, VoipPage, … |
SetTitleBar | TabbedPage, WebAppPage, VoipPage, GroupCallPage, LiveStreamPage |
A WindowBase that owns the WindowContext and exposes protected virtual OnActivated,
OnCloseRequested, OnVisibilityChanged, OnConsolidated would:
+=/-= pair into the base, which is the pairing rule in CLAUDE.md
enforced structurally rather than by review;WindowContext.Current from every root, since the base holds it —
finishing what 0.4's constructor injection started;ApplicationView and the GetForCurrentView singletons stop
being touched by roots at all, and are reached only through the base;OnCloseRequested is driven by SystemNavigationManagerPreview or by WM_CLOSE becomes an
implementation detail of WindowBase, invisible to the twelve roots above it.The roots as they stand: RootPage, StandalonePage, TabbedPage, WebAppPage, SharePage,
VoipPage, GroupCallPage, LiveStreamPage, TextEditorRichPopup, plus the overlay windows
(GalleryWindow, StoriesWindow, OverlayWindow). They currently derive from four different
bases — Page, UserControl, UserControlEx — which is part of why the wiring is duplicated.
Rename while doing it: they are named *Page but are not pages. WebAppWindow,
TabbedWindow and so on. Beware the cost — Telegram.csproj is not globbed (~1240 explicit
Compile/Page entries), so a file rename means editing both csproj files and every
x:Class. Worth splitting the rename from the base-class change so neither blocks the other.
0.14 XamlRoot and UIContext are per-island — verified. Gate 1.8d/1.8e, Fela's
question and the right one to ask, because the whole ForXamlRoot design rests on it:
1.8d XamlRoot same: False | UIContext same: False
1.8e Popups: open in A: 1 | seen from B: 0 (scoped correctly)
Two islands on one thread get distinct XamlRoots and distinct UIContexts (compared by
reference, as Fela suggested — UIContext is the identity token). 1.8e is the practical half:
a Popup opened against window A's XamlRoot is invisible to GetOpenPopupsForXamlRoot(rootB).
So the rule for 0.10 is now proven rather than assumed:
| keyed on | scope with N windows on one thread |
|---|---|
GetForCurrentView() | per thread — collapses (1.8c: same ConnectedAnimationService) |
XamlRoot / UIContext | per window — stays correct |
WindowContext.ForXamlRoot, GetOpenPopupsForXamlRoot, FocusManagerEx.TryGetFocusedElement
and MessagePopup.ShowAsync(XamlRoot, …) all keep working. That is what makes 0.10 a
mechanical conversion rather than a design problem.
0.15 No UI [ThreadStatic] may survive.
State, 2026-08-22. The pattern is settled and being applied:
ConditionalWeakTable<XamlRoot, T>, plus a new XamlRoot.TryGetContent<T> extension that
also retires the try/catch around XamlRoot.Content throwing on a closed window.
Converted so far (uncommitted): ContentPopup._currentDialogShowRequest, AnimatedImageLoader,
ProfilePicture.Loader, OverlayWindow, PaidReactionService.
Still [ThreadStatic], and inconsistent with the above — their Release() takes no
XamlRoot where the converted ones now do: PlaceholderHelper, MessageBubbleBrush,
ProfileCell (4). Note ProfileCell.xaml.cs is cp1252, not UTF-8; a scripted rewrite that
assumes UTF-8 will corrupt it.
Original entry: 0.15 No UI [ThreadStatic] may survive. Fela's rule. The distinction that matters:
A [ThreadStatic] cache of XAML objects is correct and must stay. Brushes, styles and
CompositionBrushes are thread-affine, so caching them per thread is exactly right, and
sharing them between windows on the same thread is fine — that is the point.
Theme._light / _dark / _lightBackground / _darkBackground (both theme classes)ProfileCell.t_accent / t_transparent / t_bodyStyle / t_secretStyleMessageBubbleBrush._brushesInterop ICompositionTargetStatics + the two token dictionaries (CompositionTarget is
genuinely per-thread)PlaceholderHelper._foreground, FormattedTextBlock.RelativeDateService._currentA [ThreadStatic] standing in for "the current window's X" is the bug — it only reads as
per-window because UWP gave every view its own thread:
WindowContext._current — item 0.10, 94 call sites.OverlayWindow.Current — a per-window overlay. WindowContext.PopupOpened/Closed,
NavigationService and TLNavigationService all reach for it; two windows on one thread
would share one overlay.ContentPopup._currentDialogShowRequest — worse than shared state, it is a
serialisation gate: while (_currentDialogShowRequest != null) await … queues dialogs so
only one shows at a time. Per-thread today means per-window; with several windows on a
thread, opening a dialog in one window would block a dialog in another.PaidReactionService._toast and GroupCallPaidReactionService._toast — per-window toasts.Theme.Current — cleared in WindowContext (line ~453), so it looks per-window. Needs a
decision: is a per-window theme override a real feature, or is this incidental?Not UI, correctly per-thread, leave alone: Profiler.t_scopes, WatchDog._supersede* and
_reporting, Td/Client._writer, Td/ClientJson._writer / _buffer.
The shape to migrate onto: one ConditionalWeakTable<XamlRoot, T> per type, not a single
per-window bag wrapping everything. Reasons, in order of weight:
WindowContext.OnShutdownCompleted currently hand-maintains
eight teardown calls — Theme.Current = null, ThemeIncoming.Release(), ThemeOutgoing.Release(),
PlaceholderHelper.Release(), MessageBubbleBrush.Release(), and two that already take a
XamlRoot (AnimatedImageLoader.Release(XamlRoot), ProfilePicture.Loader.Release(XamlRoot)).
A weak table frees its entry with the window, so none of those calls are needed. The migration
is already half-done and inconsistent.ShutdownCompleted, i.e. thread
shutdown. With several windows per thread, closing one window does not end the thread, so the
block stops firing at the right time. Per-XamlRoot storage is the fix, not just a tidy-up.Common, Controls, Services
and Navigation; a type wrapping all of it must reference all of it, and becomes the file
everyone edits to add state.Verified in the spike before recommending it:
| gate | result |
|---|---|
1.8g XamlRoot RCW identity | same reference every read, and from any element in the tree — so CWT's reference-only keying is safe |
| 1.8i per-window isolation | entries keyed on window A are invisible from window B |
| 1.8j automatic release | window destroyed → XamlRoot alive: False, entries left: 0 |
Two traps the spike surfaced:
XamlRoot is null until Loaded (1.8h: at gate time: NULL | at Loaded: set), and
TryGetValue(null, …) throws ArgumentNullException rather than returning false (1.8i).
Every lookup needs a null guard. This is also why WindowContent takes its WindowContext in
the constructor rather than resolving it.GetValue's createValueCallback may run more than once under contention — only one result
is stored, the rest are discarded. Keep it cheap and side-effect-free, and cache the delegate,
as TextSelectionCoordinator already does.ConditionalWeakTable thread safety — measured, not cited. A probe in
C:\Source\XamlIslandSpike\probe (8 threads x 40,000 interleaved
Add/Remove/TryGetValue/GetValue over 256 shared keys):
| check | result |
|---|---|
| mixed read/write hammer | 0 unexpected exceptions, 0 corrupted reads |
GetValue callback under contention | 61 surplus runs over 400 rounds x 8 racing threads; 0 rounds where callers disagreed on the value |
| enumeration while another thread mutates | survived 2000 full enumerations, no throw |
So the table is safe, and two nuances follow:
GetValue's createValueCallback genuinely runs more than once — the table stays
consistent (one value wins, every caller sees it) but the surplus results are discarded. The
factory must therefore be cheap and side-effect-free: a callback that subscribed an event,
started a timer or allocated a native handle would leak on every surplus run, silently, and
only under contention.And the caveat that matters most here: a thread-safe table says nothing about the values.
SolidColorBrush, Style and CompositionColorBrush remain thread-affine. Keying per window
satisfies that naturally, because a window belongs to one thread — but the table is not what
guarantees it.
0.16 Island teardown order — found by crashing it. Destroying an island's HWND while the
DesktopWindowXamlSource still holds content takes the process down; the XAML core is left
pointing at a dead window. The working order is: detach content (Content = null), Dispose()
the source, then release the native side and let WM_DESTROY run. Relevant to 0.4: whatever
replaces ViewService has to tear down in that order.
0.17 When XamlRoot exists — read out of the WinUI core. The full XAML core is at
C:\Source\microsoft-ui-xaml\src\dxaml, which settles these questions properly.
element.XamlRoot is XamlRoot::GetForElementStatic (XamlRoot_Partial.cpp:220):
VisualTree* visualTree = VisualTree::GetForElementNoRef(element->GetHandle());
if (visualTree) xamlRootInsp = visualTree->GetOrCreateXamlRootNoRef();
So: the XamlRoot object is created lazily on first request, and it is reachable exactly when
the element is attached to a VisualTree. There is no "window initialised" moment that creates
it; it appears the instant content is attached — Window.Current.Content = x on UWP,
DesktopWindowXamlSource.Content = x in an island. That matches gate 1.8d, where the island
root had a XamlRoot immediately after IslandWindow.Create, and 1.8h, where a child inside a
ScrollViewer template did not.
Now used in the app: WindowContext.SetContent captures _xamlRoot and fills _mapping
immediately after _window.Content = _content, and the Loading handler it used to need is
gone.
Also worth knowing: put_XamlRoot exists and fails with ERROR_CANNOT_SET_XAMLROOT_WHEN_NOT_NULL
— you can assign a XamlRoot to a still-detached element (that is how a Popup gets one before
opening, as gate 1.8e does), but only once.
Loading fires immediately before ApplyTemplate, in the same measure pass
(framework.cpp:1493 in CFrameworkElement::MeasureCore):
RaiseLoadingEventIfNeeded();
if (!bInLayoutTransition) { IFC_RETURN(InvokeApplyTemplate(&bTemplateApplied)); }
That is the only hook where an element is parented (so XamlRoot resolves) but its template —
and therefore its ThemeResource lookups — has not yet inflated. Re-verified 2026-08-23
against framework.cpp, because it is surprising enough to be worth not taking on trust.
Four properties of Loading follow from RaiseLoadingEventIfNeeded at framework.cpp:1703,
and they decide what it can be used for:
MeasureCore, not from tree insertion. An element that is never
measured never gets it.Raise(..., fRaiseSync: TRUE) — so the handler has finished before
InvokeApplyTemplate runs on the next line. That is what makes it usable for merging a
dictionary that the template's lookups must see.m_firedLoadingEvent; it does not fire again when an
element is reparented or recycled.ShouldRaiseEvent gates it, so with no handler attached
the event never fires and the latch is never set.The practical limit: in a Loading handler the template does not exist yet, so
GetTemplateChild returns nothing. Assigning Resources is fine — that is not a template
part — but anything reaching for template children has to wait for OnApplyTemplate.
Loading is not the same as the top of OnApplyTemplate, which is the obvious-looking
alternative. InvokeApplyTemplate inflates the template at framework.cpp:1209 and only calls
the override at 1233, after RefreshTemplateBindings. So by OnApplyTemplate every
{ThemeResource} in the template has already resolved against whatever dictionary was in
scope. Assigning there still works — ThemeResource re-evaluates when the dictionary changes,
which is why it worked when tried at ContainerContentChanging — but it works by resolving
twice. Loading resolves once.
ContainerContentChanging is later than it looks. ListViewBase_Partial_ContainerPhase.cpp
forces a measure before raising it, specifically to materialise ContentTemplateRoot:
// force measure. This will be no-op since content has not been set/changed
// but we need it for the contenttemplateroot
IFC_RETURN(containerAsISI.Cast<SelectorItem>()->Measure(measureSize));
... Raise(ContainerContentChanging) ... then put_Content(item)
So by CCC the template is inflated and ThemeResource has resolved; merging a dictionary there
works (Fela tested it) but only via re-evaluation — a second pass per bubble.
0.18 Per-window chat themes - done 2026-08-23, and the rule this item used to state is gone. It went round three times, so the conclusion is worth keeping along with why the two earlier answers were wrong.
How bubble theming works, which is what made it look impossible: [ThreadStatic] tables hold
(Color, SolidColorBrush); every ThemeOutgoing/ThemeIncoming copies the same brush
references into its ThemeDictionaries; a per-chat theme mutates ~20 brush colours in place and
every bubble in the window repaints. No walking, no re-resolution. It is very cheap and the
cheapness is the point - which is why the fix could not be "one dictionary instance per bubble".
The first answer was a rule: a window that renders chat content owns its thread. It followed
from App.xaml resources being instantiated per thread - visible in Theme's own constructor,
_isPrimary = Current == null over a [ThreadStatic] Current, which only makes sense if
Theme() runs once per thread. Thread == view was what made the scheme correct.
It was too strong - Fela, 2026-08-23. The app theme is global and every window shows the same
one; the only thing that varies per window is the chat override, and that touches exactly
Outgoing.Update, Incoming.Update and the background. It never runs the app colour pass. The
message brushes live in bubbles, bubbles are content, and gate 1.11 measured that content scopes
are per island - so a per-window override was always possible.
What shipped, four commits: de38e4934 moved the message brushes onto the window's theme,
04319fbaf moved the chat override onto the window, 8d2acea34 forwarded the window's brushes
to popups and flyouts - which closes the one loose end this item had, the popups that render
bubble keys and used to inherit the override by accident - and efa19f6a1 reduced
Theme.Current to the app-level shim it now is. WindowContext.SetContent merges the window's
own Incoming.CreateDictionary() into the presenter's resources; MessageBubble takes
Outgoing from the window.
What is left is not a limit: the app theme stays per thread because Application.Resources
is what popups resolve from, and it is meant to be global anyway. Nothing caps 1.8a any more -
several chat windows can share a thread.
0.19 Theme.Current -> WindowContext.Theme, in three steps. Fela's proposal, and the
right destination — but a straight rename is churn, because the 50 Theme.Current sites do
not all want a window, and because moving the handle does not move the state.
a. Split the global settings off first. 30 of the 50 sites are MessageFontSize (9),
CaptionFontSize (6), MonospaceFontFamily (11) and XamlAutoFontFamily (4). They come out
of _isolatedStore and are identical in every window; per-window they become N copies of one
setting, and a read that is currently a [ThreadStatic] field — free — turns into a
_mapping lookup plus two derefs, on FormattedTextBlock's path. Split value from
materialisation: the size and the font name are global statics; the FontFamily instances
stay per-window, since FontFamily is a DependencyObject and therefore thread-affine, which
is why they are per-thread today. Doing this first shrinks the problem to 13 sites.
b. Move the dictionaries to the window root. Theme is a ResourceDictionary merged at
App.xaml:515, so XAML constructs it — one per thread (see 0.18). WindowContext.Theme can
only point at that instance; a per-window handle onto per-thread brushes is worse than an
honest [ThreadStatic], because it reads as solved. So <common:Theme/> and
<common:ThemeIncoming/> move out of App.xaml into the window root's Resources
(WindowContent), which is per-window by construction. WindowContext then owns the
instance and merges it rather than discovering it, and ThemeResource resolves one level
earlier than App rather than one later. ThemeOutgoing cannot join them at the root — it
redefines the same keys as ThemeIncoming — which is what ThemeOutgoing2.ForXamlRoot plus
the Loading hook from 0.17 is for.
c. Then the handle, for the 13 that want it. Parameters (8 sites) is already
_parameters[WindowContext.Current.ActualTheme] — a per-window lookup wearing a static's
clothes; window.Theme.Parameters deletes the hop. And the broadcast in
AppearanceSettings.cs:202 is the case that makes it worth doing:
await WindowContext.ForEachAsync(window =>
{
Theme.Current.Update(theme); // "current" = whichever thread this landed on
correct today only because ForEachAsync dispatches to each window's own thread.
window.Theme.Update(theme) is right by construction, and stays right the day two windows
share one. Same for ChatBackground, ChatTheme, DarkSettings, UpdateScrolls,
UpdateEmojiSet.
Rule for the call sites: anchor the WindowContext on the element and cache it at Loading
or Loaded. Never a lookup per read — that is the one way this change can cost more than it
buys.
State, 2026-08-22. Fela has done most of (a) and (c); (b) is untouched.
Theme.Current is down from 50 sites to 25.
MessageFontSize/CaptionFontSize moved to SettingsService — 72 lines out of
Theme.cs. MonospaceFontFamily and XamlAutoFontFamily have not moved, and they are
15 of the 25 remaining sites. They are the FontFamily-is-a-DependencyObject case: the
font name is global, the instance has to stay per-window.WindowContext.Theme exists as => Theme.Current, a forwarder, with ViewModelBase
now exposing Window so view models can reach it. Call sites move first, the
[ThreadStatic] behind it swaps later. The broadcast is already
window.Theme.Update(theme) — the case that motivated the change.Theme.Current and genuinely wants a window: ChatBackgroundControl (3),
PaymentFormViewModel.Parameters (1), and the four Update(...) entry points in
SettingsThemeViewModel, BlankPage, ChatView and SettingsAppearancePage.App.xaml:515 still merges <common:Theme/> and <common:ThemeIncoming/>, and the
per-bubble dictionaries in ChatView.xaml are back to the original shape.ThemeOutgoing2 (the ConditionalWeakTable<XamlRoot, …> experiment) is in Theme.cs with
no consumers. Decide whether it becomes (b) or gets deleted; leaving it is the worst of
the three. 0.20 Untangle Theme / ThemeIncoming / ThemeOutgoing. Supersedes 0.19b, which
described the destination without saying how to get there. Fela's read: they are tangled for
two reasons, one historical — per-chat appearances did not exist when this was written — and
one structural, that every UWP window needs a Theme anyway, so it may as well be the thing
that resolves the other two.
The domain, which the code does not currently express. Three layers over two independent axes, each layer overriding the one above it, per base, with the base flippable at any time:
| colours | background | |
|---|---|---|
| base | Light / Dark | — |
| app | AppSettings.Appearance[base] — Classic / Day / Tinted / Custom(file) / Accent | settings.Background |
| chat | ChatTheme.LightSettings / .DarkSettings | ChatBackground |
What the code does instead. There is one resolution — (base, appearance, chatTheme) -> values — and it is written twice: the four-case decision (chat theme? custom file? accent?
plain) appears in Update(ApplicationTheme) at 285-300 and again in the else branch of the
Local Update at 240-260, and the tint mapping is duplicated verbatim at 205 and 314. There
are five ThemeOutgoing.Update call sites for what is one operation. There is no Local path
and a Global path; there is one function split in half by which caller arrived.
Three defects follow directly, all present today:
Update(ApplicationTheme) resolves and updates the bubble
dictionaries at 343, then calls the Local update at 305, which resolves again and updates
them again at 225. Every app theme switch with a chat theme active pays twice._lastAccent is an int? holding only the accent,
yet it guards the assignment of _lastLightSettings, _lastDarkSettings and
_lastChatTheme. A chat theme that changes while keeping the same accent skips the update
and leaves all three stale.AppSettings.Appearance[base].Type
at 204 to pick the tint for a chat theme. Probably intended — chat themes tinting to the
user's chosen style — but nothing says so, which is why the two halves cannot be told apart
by reading them.Also worth knowing before moving anything: Parameters is a byproduct. _parameters[…] is
assigned only at line 466, inside the 400-line colour pass, so the bot-API payload cannot be
obtained without running brush computation.
The constraint that fixes the shape. The bubble keys are referenced 44 times across ~15
XAML files — MessageBubble.xaml and the content controls, but also ChatPinnedMessage,
SharedLinkCell, SendFilesPopup, Generic.xaml. The ThemeResource dictionary-scoping
mechanism is therefore load-bearing and cannot be replaced by assigning brushes in code. The
incoming set is the app-wide default, which is why App.xaml merges it; outgoing is a scoped
override.
Target shape.
Theme, one per window, owns the resolved values and the brush objects. The [ThreadStatic]
tables become instance fields — and no ConditionalWeakTable is needed either, because
ownership does the scoping that ThemeOutgoing2 was reaching for with one. That is why that
experiment felt like it was fighting the grain; delete it, it has no callers.Theme itself, so App.xaml carries one entry instead of
two and every non-bubble consumer resolves exactly as now.Theme.Outgoing is one dictionary per window, merged into an outgoing bubble's
Resources in code at Loading instead of being constructed by the DataTemplate. Cheaper
than today, where every realised bubble builds its own ResourceDictionary, and Loading is
the correct hook because it runs immediately before ApplyTemplate (0.17), so the lookups
happen after the merge without the second pass that ContainerContentChanging would cost.ChatTheme was supplied. Change detection then compares the resolved
inputs, which fixes the stale-field bug for free, and the background axis resolves alongside
as chatBackground ?? settings.Background in one place rather than only in Local.ThemeOutgoing is merged in six places, not one — a belief worth correcting before the
code moves. Besides ChatView.xaml:413 it appears in BackgroundPopup.xaml:629,
ThemePreviewPopup.xaml (114, 120, 130), SettingsAppearancePage.xaml:42 and
SupergroupProfileColorPage.xaml:152. All five extra sites are previews that render fake
outgoing bubbles, none of them inside a MessageBubble, so "the bubble merges it at Loading"
does not cover them.
Settled: all of them show the current theme except ThemePreviewPopup, which by definition
shows one that has not been applied. So the shared per-window Theme.Outgoing serves five of
the six, and ThemePreviewPopup keeps building a throwaway dictionary of its own — an
exception stated in the design rather than an accident of it. ChatThemeCell already works
that way from the other direction, computing MessageBackgroundBrush through
ThemeAccentInfo.Colorize rather than reading any dictionary.
Merge from code-behind at each site, not from XAML. Six places is not many, and it inverts
the hard part: rather than a ResourceDictionary having to discover which window owns it, the
merging control already has a XamlRoot and simply asks for Theme.Outgoing.
The dictionary stays per bubble — a shared instance is not possible. Assigning one
ResourceDictionary to two elements throws ArgumentException, and the core says why:
FrameworkElement's Resources setter calls resources->SetResourceOwner(this)
(framework.cpp:343) on a dictionary that holds a single owner pointer, later used to find
the visual owner for theme resolution (Resources.cpp:1344) — and that owner is propagated
into the merged and theme dictionaries beneath it (Resources.cpp:133, 141). So merging a
shared instance is no better than assigning one: even where it does not throw, the owner would
flap between bubbles and break {ThemeResource} re-evaluation for all but the last.
That removes the allocation saving that first motivated the change — the constructor's two
child dictionaries and ~40 insertions stay, per realised bubble — but not the point of it. Only
the wrapper has to be per bubble; the brushes inside are the window's, shared and
recoloured in place, which is what makes one theme change repaint every bubble without touching
any of them. So Theme exposes CreateOutgoing(), not an Outgoing property, and what moves
off [ThreadStatic] is the brush state rather than the dictionary. A rewrite has to keep
ThemeDictionaries["Light"] and ThemeDictionaries["Default"] — dark is stored under
Default, not Dark.
The hook only matters in MessageBubble, and Loading is the right one. IsOutgoing is a
plain C# property, not a DependencyProperty — nothing binds to it, animates it or styles
it, and a DP would box the bool and cost a property-system lookup on a per-bubble path for
nothing. XAML can still set it from the template, because a literal attribute needs only a
settable public property; it is bindings and animations that require a DP. The setter cannot
do the assignment itself, because at parse time the
element is not parented and there is no XamlRoot to resolve the window's Theme from;
Loading is the first moment there is one, and it runs immediately before ApplyTemplate
(0.17), so the lookups resolve once against the dictionary that is already in place. Firing
once per element is sufficient here precisely because Resources persists on the instance —
a recycled bubble keeps the assignment and needs no second pass. The five previews can use
whatever hook each type already has with a XamlRoot in hand.
Worth checking while in there: the incoming template merges <common:ThemeIncoming /> per
bubble as well (ChatView.xaml:424) even though the same dictionary is already merged in
App.xaml:516. Sibling elements do not inherit each other's resources, so the per-bubble copy
looks redundant today — if it is, incoming bubbles need no dictionary at all once Theme owns
the default set, and only outgoing ones get an assignment.
The scoping, settled 2026-08-23. Fela's scheme, and gate 1.11 measured that each level behaves:
| dictionary | merged at | reaches |
|---|---|---|
Theme, including the default incoming brushes | App.xaml | everything, PopupRoot included - the fallback for anything not forwarded |
| the window's incoming | WindowContext.Content | all window content - bubbles, cells, headers, pages |
| the window's outgoing | MessageBubble when IsOutgoing | that bubble |
| the window's incoming again | ContentPopup.ShowQueuedAsync, plus three flyouts | popup content that should follow the chat override |
Two instances, not one. The app-level set and the per-window set have to be different
MessageBrushes: today UpdateMessages recolours the very brushes that are folded into the
app dictionary, so a chat override would repaint the fallback and every window with it. So
Theme.Incoming/Theme.Outgoing become the defaults, recoloured only by the app theme pass,
and WindowContext owns a second pair that the chat override writes to.
That relocates the override path: Theme.Update(ElementTheme, ChatTheme, …) acts on the
window's pair rather than Theme's own, so it becomes a WindowContext operation and its
three callers - ChatView, BlankPage, SettingsAppearancePage - follow it there.
The last row is the price, and it is a fair one. The message keys are referenced by ~75 XAML
files; nearly all are window content, but seven are popups - SendFilesPopup,
BackgroundPopup, BackgroundsPopup, SendLocationPopup, SendGiftPopup,
ReceivedGiftPopup, BusinessChatLinkPopup - and all seven derive from ContentPopup, so
ShowQueuedAsync covers them in one place; it already takes the XamlRoot, which is the
legitimate use of ForXamlRoot.
Three flyouts build their own Popup and are not covered by it: EmojiMenuFlyout,
MessageEffectMenuFlyout and ReactionsMenuFlyout each new Popup() and host an
EmojiDrawer, whose markup uses the message keys. They are UserControls, so they need the
same forwarding where they create the popup - three sites, each with an anchor element in hand.
Ten explicit call sites in place of a global that currently works by accident, and
SendFilesPopup keeps showing the chat's theme because someone decided it should.
Order. Delete ThemeOutgoing2 first — 184 lines, no callers, and a parked half-alternative
beside the real one is most of what makes the file read as a mess. Then the single resolver,
since it is what the rest depends on. Then Theme taking ownership of both directions. The
Outgoing/Incoming de-duplication falls out of that last step rather than being its own job.
0.11 Find a better hook for type-to-search / type-to-compose in ChatView and
MainPage. Both now subscribe to WindowContext.CharacterReceived and then disambiguate by
state — MainPage bails unless MasterDetail.NavigationService?.Frame.Content is BlankPage,
ChatView assumes it wins otherwise. That is still a broadcast-plus-guard design; it is only
less bad than the CoreWindow version, not actually right.
Both are in the same tree, so subscribing at the window root was the expedient answer: a
character typed with focus in the chat list has to reach the open chat's composer, which a
subscription on ChatView itself would never see. Better shapes to consider:
MainPage subscribes and routes to whatever is currently open, since it is the
one that knows;WindowContext: whoever is the active text target registers
itself, and the window forwards to exactly one subscriber rather than broadcasting.The second also answers what happens with several windows on one thread, so it pairs with 0.10.
0.7 Route file access through a path-first helper. 40 FutureAccessList sites, 25
StorageFile. statics, 12 FileOpenPicker, 6 CameraCaptureUI, 4 FileSavePicker, 3
FolderPicker, 2 KnownFolders, 28 Launcher.. Adding an HWND parameter at this seam is
what makes IInitializeWithWindow a one-line change later instead of 90.
Leave ApplicationData.Current alone — it works on desktop, and the 45 remaining sites are
all LocalFolder/TemporaryFolder. Its settings half is already done: one line, behind
ISettingsStore.
0.8 Collect the lifecycle handlers. 29 Suspending + 16 Resuming behind one
app-owned event pair, so the desktop implementation can raise them on power/session
transitions — or never.
0.9 Keep it from getting worse. Extend the TG100x analyzer set with a rule that
flags ApplicationView, CoreApplication, Window.Current and CoreWindow outside
Navigation/ and Services/ViewService/. Cheap, and it is the only thing that stops 0.2–0.6
from silently regrowing.
C:\Source\XamlIslandSpike)Answers the gate. Throwaway, in the pattern of CustomTitleBarSpike / DustSpike. Ordered so
the most likely failure comes first.
1.0 VoipCallCoordinator — closed by reading the call sites rather than by spiking
it. The integration is gated on ReserveCallResourcesAsync, i.e. the UWP background-execution
reservation a Win32 process does not need; the app already degrades to _systemCall == null
on a supported path; and it is currently the cause of the call-window focus bug. Whether it
works on packaged desktop stopped mattering. See the Cons entry.
1.1 Desktop net10.0-windows10.0.26100.0 exe that references
Microsoft.Windows.UI.Xaml.dll and compiles against Windows.UI.Xaml.Hosting. Passes — no
UseUwp, plain dotnet build.
1.2 WindowsXamlManager.InitializeForCurrentThread() + a DesktopWindowXamlSource
attached to a plain Win32 HWND, showing a stock Button. Passes, given a
DispatcherQueueController on the thread and maxversiontested in the app manifest.
1.3 A C# custom control with its own ResourceDictionary and a custom
VisualStateManager, resolved through the generated XamlTypeInfo metadata provider.
Passes, incl. under AOT. Needs UseUwpTools+UseUwp, VS MSBuild,
DISABLE_XAML_GENERATED_MAIN, an App.xaml, and a corrected XBF layout.
1.4 XamlDirect.GetDefault() inside the island — create and mutate a Run the way
FormattedTextBlock does. Passes. This is the WinUI 3 blocker, and it is not one here.
1.5 ElementCompositionPreview.GetElementVisual + a running Compositor animation.
Passes. SurfaceImageSource fed from D2D is still to do.
1.6 PublishAot=true. Passes — 4.0 MB single-file exe, 79 ms median startup
against 215 ms for the JIT layout, all seven gates unchanged. Needs
BuiltInComInteropSupport=false and vswhere on PATH.
1.7 Non-client: a custom caption that maximizes and snaps. Passes. The thing UWP
cannot do — a UWP app extending into the title bar can hide, minimize and close, but never
maximize, and loses snap layouts with the system caption. Three pieces, in
IslandWindow.NonClient.cs:
WM_NCCALCSIZE — let the default proc compute the frame, then restore the original
top, so the frame survives on three sides and the client area reaches the top edge. When
maximized the top must come back in by SM_CYSIZEFRAME + SM_CXPADDEDBORDER, because a
maximized window is deliberately larger than the monitor by the frame width; without that
the caption sits off-screen.WM_NCHITTEST — removing the top of the frame takes the top resize border with it, so
it has to be answered by hand. The other three sides still come from the default proc.WS_EX_LAYERED | WS_EX_NOREDIRECTIONBITMAP.Two things about that third piece that cost time and are not in any article:
WM_NCHITTEST. It is a child window, so the top-level window never
sees the caption and dragging does not work at all. The drag bar answers instead.HTCAPTION drags nothing. The hit test alone is not enough; the
drag bar must forward WM_NCMOUSEMOVE, WM_NCLBUTTONDOWN, WM_NCLBUTTONDBLCLK,
WM_NCLBUTTONUP and the right-button pair to the parent with SendMessage whenever the
hit is HTCAPTION or HTTOP. That is what produces dragging, double-click maximize, the
system menu and the top resize.And the split that matters for expectations: returning HTMAXBUTTON earns the snap layouts
flyout for free, but not the click — Terminal's own words, "the buttons won't work as
you'd expect". A release over a button is turned into WM_SYSCOMMAND with SC_MINIMIZE,
SC_MAXIMIZE/SC_RESTORE or SC_CLOSE by hand.
Left undone: hover feedback on the caption buttons. Done, 2026-08-24 - see 1.7a.
It went exactly the way this paragraph guessed: TrackMouseEvent with TME_NONCLIENT, armed
once per visit, and the hover pushed into XAML by hand.
One incidental lesson from the same session: the spike merges no XamlControlsResources, so
there are no theme brushes to inherit and every colour in it is a literal — which made it easy
to hardcode dark and get it wrong on a light system. It now derives its palette from
Application.Current.RequestedTheme, which is also what DWMWA_USE_IMMERSIVE_DARK_MODE should
be given rather than a constant true.
1.7a One caption-button model for both hosts, 2026-08-24. Fela's design, and the piece that makes the Win32 caption real rather than a hit-test with nothing drawn in it.
WindowPresenter - the WindowContext's own root, which every window already has - grows a
template holding minimize, maximize and close, and a CaptionButtons flags property saying
which of them the window wants. Buttons only: no caption text, no icon, because no window in
this app wanted either.
The rule that makes one setting serve two hosts. All means "an ordinary window", and an
ordinary UWP window already has all three drawn by the shell - better than anything we could
draw, since it minimizes, maximizes and has the system menu. So on UWP All hides ours and
anything less than All takes the shell's caption away and draws our own; on Win32, where
there is no shell caption, the setting is always honoured as given. One boolean per host half,
HasSystemCaptionButtons, is the whole of the difference.
What UWP can actually draw is Close and only Close - it has no way to minimize or maximize
a view, which is gate 1.7's whole reason for existing. So the values that mean anything on both
hosts are None, Close and All; the other combinations are Win32 only. Both call sites
that wanted this - WebAppWindow and PaymentFormPage - wanted Close.
Three copies of the same hand-drawn X (TabbedWindow.xaml, WebAppWindow.xaml,
PaymentFormPage.xaml - Path data M0.5 0.5 L9.5 9.5..., #C42B1C on hover) collapse into
the one template. The other two glyphs follow its convention: 10x10 box, 1px stroke, and a
restore glyph swapped in by a visual state when the window is zoomed.
Four things that were not obvious going in:
SetTitleBar. That was the shape it replaced - SetTitleBar(element, collapsed: true) - and it cannot stay one: WindowContent.OnPopupOpened calls
SetTitleBar(null) and OnPopupClosed calls SetTitleBar(element), both with the default
argument, so every popup opening over a web app would have quietly handed the caption back to
the shell. The buttons are a window-level setting, kept on WindowContext because a root sets
it from its constructor - before there is a presenter to set it on.HTCLOSE/HTMAXBUTTON/HTMINBUTTON; that
claim is what earns the snap layouts flyout, and it already turns the click into a
WM_SYSCOMMAND. So XAML draws them and Win32 answers them, and only the appearance of
hover and pressed has to cross over - SetCaptionButtonState, pushed in from the drag bar's
WM_NCMOUSEMOVE. It is a pointer-rate path, so it early-outs on an unchanged state on both
sides of the boundary.CaptionHeight
went 32 -> 40, which is what every title bar in this app is, and DragBarHitTest now walks
the same flags the template lays out rather than assuming three buttons.OnWindowCloseRequested - Fela, rather than the separate
hook I had written, and the build proved him right twice over. WebAppWindow already
overrode it, so my hook would have been a second copy of the same confirmation; and it does so
with a deferral, which a synchronous "did the root handle it?" would have walked straight
past - the override is async void, so it returns at its first await with Handled still
false. WindowCloseRequestedEventArgs now carries an app-side deferral
(TaskCompletionSource, allocated only if a handler asks for one) and RequestCloseAsync
waits on it. The lesson is the ordinary one: the type already told me it could defer.And one thing that followed from Fela's own reading: no maximize button means no resizing.
The two are one affordance, so Close alone drops WS_THICKFRAME and WS_MAXIMIZEBOX and the
top resize border with them.
Not done: the presenter is where a window's insets should come from now that the app draws
the buttons - roots still pad themselves from SystemOverlayMetrics.RightInset, which is the
shell's number and is 0 on Win32. WebAppWindow and PaymentFormPage keep a 46px spacer where
their button was.
1.8a Two islands on the same thread. Passes — two HWNDs, two
DesktopWindowXamlSources, one WindowsXamlManager, both hosting compiled markup. UWP's
one-thread-per-view came from CoreApplication.CreateNewView(), not from XAML, so islands are
not bound by it. See below — this is a benefit, not a parity item.
1.8b Popups and flyouts near the island edge. Passes — a Popup with
ShouldConstrainToRootBounds = false opens outside the window, over the desktop, from
inside the island. The six composer sites survive.
1.8c What GetForCurrentView() resolves to with many islands on one thread.
Answered, and it is a caveat, not a pass: ConnectedAnimationService is the same
instance for both islands while their XamlRoots differ. Per-view singletons are really
per-thread. Feeds Phase 0 item 0.5b.
1.9 What DependencyObject.Dispatcher returns inside an island. Present, not
null — unlike WinUI 3. And element.DispatcherQueue does not compile on UWP at all.
Together these shrink Phase 0 item 0.1 to a handful of sites; see it for the detail.
1.10 Mica / acrylic backdrop. Passes, but only through an undocumented API. Measured on build 26200.
Mica is the one item on this list UWP cannot have at all, because the backdrop is a DWM
attribute on an HWND and a UWP app does not own one. Terminal's entire implementation is a
single call (IslandWindow.cpp:1847):
const int attribute = newValue ? DWMSBT_MAINWINDOW : DWMSBT_NONE;
DwmSetWindowAttribute(hwnd, DWMWA_SYSTEMBACKDROP_TYPE, &attribute, sizeof(attribute));
Documented from 22621; on 22000 it fails silently and DWMWA_MICA_EFFECT (1029) is the only
route, which Terminal never bothered to implement.
That call alone is not enough, and the failure is confusing. With the attribute accepted
(hr == 0) the backdrop appears in the non-client area only — acrylic shows in the title
bar and the client area stays an opaque sheet. XAML islands paint what Terminal calls the
"emergency backstop" behind the island content, and nothing in the public surface turns it off:
root Background = null, WS_EX_NOREDIRECTIONBITMAP, suppressing the class brush on
WM_ERASEBKGND and DwmExtendFrameIntoClientArea(-1,-1,-1,-1) together do not.
Terminal removes it with TerminalTrySetTransparentBackground (GH#603), which comes from
Microsoft.Internal.Windows.Terminal.ThemeHelpers — an internal package, not on nuget.org.
What it does, read out of the shipped TerminalThemeHelpers.dll: activate
Windows.UI.Xaml.Window, call IWindowStatics::get_Current, QI the result for
{06636C29-5A17-458D-8EA2-2422D997A922} (IWindowPrivate), and call vtable slot 7 — a boolean
put_TransparentBackground. Reproduced in the spike as WindowPrivate.cs; the property reads
back False -> True and the backdrop then fills the whole window, client area included,
confirmed by A/B'ing DWMSBT_NONE against DWMSBT_TRANSIENTWINDOW on the live HWND.
Two things fall out that matter beyond the effect itself:
Window.Current is non-null inside an island. Terminal depends on it, and the spike
confirms it. It is a per-thread stub with no CoreWindow, which also means
TransparentBackground is a per-thread switch, not per-window — one more entry on the
list of things that are thread-scoped when we would want them window-scoped.Terminal's other Mica lesson, for when 1.7 lands: with a custom caption, set
margins.cyTopHeight = 0 rather than -frame.top (NonClientIslandWindow.cpp:936). Any
non-empty top margin lets the DWM title bar — and the accent strip, when "show accent colour on
title bars" is set — draw over the backdrop. Their comment calls the empty rect LOAD-BEARING:
it is what makes DWM use NCHITTEST for the snap flyout.
1.13 Text input inside a ContentDialog does not work in an island. Measured
2026-08-24, and it is the largest constraint found so far.
ContentDialog TextBox - KeyDown 15, CharacterReceived 0, TextChanged 0
Three runs, same answer. Keys reach the control - its own OnKeyDown fires, it takes focus by
Tab, the caret blinks - and CharacterReceived never fires, so no character is ever produced
and nothing is inserted. RichEditBox and TextBox both.
It is ContentDialog specifically. A Popup in the same island, same window, same thread
types perfectly - both ShouldConstrainToRootBounds true and false. So it is not focus, not
the message loop, not popup hosting, and not the app's ContentPopup. Four theories died on the
way to that, three of them mine: WM_CHAR/TranslateMessage (disproved by the main window's
composer working), windowed popups (disproved by ContentPopup not setting the property), and
focus (disproved by the caret).
The reach: ContentPopup derives from ContentDialogEx derives from ContentDialog, and
153 files derive from ContentPopup. Every dialog in the app that takes text is inert on this
host - send-files captions, forward comments, folder names, link editing, polls.
Also seen, and relevant because the app queues dialogs: ShowAsync threw
COMException: An async operation was not properly started when a second dialog was shown while
one was already open. Worth confirming against ShowQueuedAsync before designing anything.
And it is not the only thing wrong with ContentDialog here.
microsoft/microsoft-ui-xaml#3577 reports the dialog's backdrop staying at its initial size when
the island window is resized while it is open - Fela hit that independently before finding the
issue. No root cause, no workaround, and nothing has come of it. Two unrelated defects in the
same control, neither being fixed, is the argument against waiting for this one to improve.
The CoreWindow route was tried, and it half works. A thread hosting islands still gets a
CoreWindow - a 1x1 invisible stub, a child of the top-level window - and #3577's workaround is
to forward WM_SIZE to it by hand. Measured 2026-08-24:
WM_SIZE: works. The smoke layer resizes with the window. Telegram\Host\CoreWindowBridge.cs
finds the stub by class name - it is a child, so no ICoreWindowInterop is needed - and posts
to it from WM_SIZE. Keep.WM_KEYDOWN/WM_CHAR and friends to the same stub takes
the process down. Removed.So the dialog's layout listens to that CoreWindow and its input does not, and gate 1.13's
missing CharacterReceived cannot be reached from there. That was the cheap hope; it is gone.
Terminal never hit this, and that is why nobody has fixed it. It is the flagship islands app
and it uses ContentDialog heavily - ConfirmCloseDialog, CloseReadOnlyDialog,
MultiLinePasteDialog, LargePasteDialog, ControlNoticeDialog, UriErrorDialog - and not
one of them contains an input control. Its only TextBox is WindowRenamerTextBox, and that
lives in a TeachingTip. So Terminal hit the resize bug hard enough to file it eighteen times
(see microsoft/terminal a4cf4e276, which is the same WM_SIZE workaround we landed on
independently) and never once typed into a dialog.
Two things follow. No upstream fix is coming, because the problem is not reported - a minimal
repro is about fifteen lines and the spike already has it, so filing it is cheap if we want to.
And as a data point only - not as a candidate - a TeachingTip holds a working TextBox. With
both Popup flavours typing fine too, the pattern is that everything except ContentDialog
works, which makes rebuilding the popup host on a Popup the known-good path rather than a
gamble. Fela on TeachingTip itself, 2026-08-24: "terrible, definitely not a replacement for
ContentDialog" - and if anything in that family were to be reimplemented in the app today, it
would be TeachingTip that needed it.
The options, none of them small:
Popup, for this host only. Popups demonstrably work, and
ContentPopup is already the app's own wrapper - so if its public surface is preserved
(Title, PrimaryButtonText, ShowAsync, Hide, the queueing), the 153 subclasses need not
change. It means rebuilding what ContentDialog supplies: layout, buttons, modality, the
smoke layer, focus and dismiss.Microsoft.UI.Xaml island, where ContentDialog is WinUI's own implementation
rather than the system one. Bigger, but this is the second thing pointing that way after
acrylic. 1.11 Resource scope across islands. Measured, and it decides 0.18. Three probes, each a
Border whose Background is {ThemeResource ScopeBrush} parsed at runtime, with red defined
in Application.Resources and green scoped to the first island's content:
| probe | resolved |
|---|---|
| island A content | green - the island's own scope |
| popup opened on island A | red - Application.Resources |
| island B, same thread | red - scopes do not leak between islands |
Islands do not rescue popups. A PopupRoot is a sibling of the content under the XamlRoot,
so its lookup reaches Application without passing through anything the app scoped - exactly as
in UWP. But content scopes are genuinely per island: a dictionary on one island's content is
invisible to another on the same thread.
Record results back in this file. If 1.3 fails and cannot be worked around, close the path and say so here.
Recorded here because it briefly looked like an argument for hurrying this project, and it was
not. Closing a secondary view access-violated inside RoUninitialize: a XamlDirect handle's RCW,
finalized after that view's XAML core was released, marshalled its Release back into the
uninitializing apartment through IContextCallback and unparented a core-less
CDependencyObject. Both ends were confirmed from a dump —
WinRT.ObjectReferenceWithContext<T>.Release() on the finalizer thread, and
interface_forwarder<IXamlDirectObject,CDependencyObject>::Release on the dying ASTA. Filed as
CsWinRT #2532; the mechanism and the fix live
in notes/net10-port-todo.md.
Two things it settled that bear on this plan:
Counting app-model API hits per file (CoreApplication, ApplicationView*, CoreWindow,
SystemNavigationManager, CoreDispatcher, Window.Current; WindowContext.Current excluded)
sorts the app into three tiers. The long tail is Phase 0 work, not forking — only the top tier
needs two implementations.
Tier 1 — two implementations, one per host.
| Class | Hits | UWP does | Win32 does |
|---|---|---|---|
WindowContext | 54 | Window + CoreWindow + ApplicationView | HWND + DesktopWindowXamlSource + IslandNative |
ViewService + ViewLifetimeControl | 34 | CreateNewView + ApplicationViewSwitcher | create an HWND, on this thread or a new one per 0.18 |
BootStrapper + App.xaml.cs | 10 | Application, OnLaunched, activation, suspend | WinMain, WindowsXamlManager, own message loop |
InputListener | 6 | CoreWindow.GetAsyncKeyState | unchanged — the stub answers key state (2.1g) |
| title bar — no class yet | — | ExtendViewIntoTitleBar + CoreApplicationViewTitleBar | WM_NCCALCSIZE / WM_NCHITTEST + drag-bar HWND (1.7) |
| tray icon | — | Telegram.Stub, a second process | Shell_NotifyIcon in-process (2.5) |
Tier 2 — one implementation, but a behaviour difference to design.
GalleryCompactOverlay (4) — ApplicationViewMode.CompactOverlay is a UWP feature with no
Win32 equivalent; picture-in-picture becomes a small topmost window the app positions itself.
The only item here where the feature, not the API, differs.WindowContent (9), CorePage (4), StandaloneWindow / TabbedWindow / VoipWindow (3/2/3)
— almost entirely title-bar plumbing, and 0.3c already turns it shared: SystemOverlayMetrics
in CorePage.cs is an app-owned value object built from CoreApplicationViewTitleBar, which
is exactly the right shape. After 0.3c only the source of the metrics forks, not the roots.OverlayWindow (3) — check whether its SystemNavigationManager use survives 0.5.Tier 3 — no fork, only Phase 0 call sites. 1-3 hits each, all mechanical: WatchDog,
TLNavigationService, MessageHelper, SettingsLanguageViewModel, LegacyIncrementalCollection,
Extensions, Interop, InstantPage, DiagnosticsViewModel, ChatView, GalleryWindow,
GalleryTransportControls, ChooseChatsPopup, SendFilesPopup, SendLocationPopup,
EditMediaPopup, RichTextWindow.
How to fork: by file inclusion, not #if. The repo already builds two flavours from one tree,
and Telegram.csproj lists every file explicitly (~1240 entries) rather than globbing — so a third
project for the Win32 host selects its own files for free. Each Tier 1 class becomes a shared
partial plus a per-host partial: WindowContext.cs keeps XamlRoot, _mapping, Theme,
NavigationServices and the per-window services, while WindowContext.Uwp.cs /
WindowContext.Win32.cs supply activation, bounds and title bar. No
preprocessor branches in shared code, and the shared half stays readable. Agreed naming is
WindowContext.Uwp.cs / WindowContext.Win32.cs — .Modern.cs would collide with the .NET 10
project's meaning of the word.
Partials also give the contract for free: shared code calling Activate() will not compile
unless every host partial defines it, so no interface is needed, and with it no virtual dispatch,
no DI registration and nothing the AOT compiler cannot see through. Four of the five Tier 1
classes are already partial.
BootStrapper is the exception and wants treating as its own item: it is
abstract class BootStrapper : Application, and the Win32 host has no Application at all, so
the base type differs rather than the members. It needs a host-agnostic core with
BootStrapper : Application on one side and a plain class on the other — a bigger rework than
the partial split the others take.
Also host-specific, and not a class today: installing a DispatcherQueueSynchronizationContext
on every UI thread. UWP does it per view for free; without it every await on a TDLib result
resumes on a TDLib thread, and the failures look like anything but a missing sync context.
Confirmed 2026-08-24, and the source is exact: the Main the XAML compiler generates - the one
DISABLE_XAML_GENERATED_MAIN suppresses - is where it was being set:
Application.Start((p) => {
SynchronizationContext.SetSynchronizationContext(
new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread()));
new App();
});
Taking over the entry point means carrying that line across, and Host\Program.cs now does, before
constructing App. Anything that gives a thread an island will have to do the same - the spike
never noticed because it awaited nothing.
The Win32 flavour ships as a packaged Win32 app — Fela, 2026-08-24: "the codebase definitely needs an identity to run". That is a decision, not a preference, and it settles more than it looks:
ApplicationData.Current (settings,
LocalState, the TDLib database path), notifications, background tasks, protocol and
file-type activation, the share target — all of it needs identity, and all of it keeps
working unchanged. None of it joins the fork.Telegram.Native / Telegram.Native.Calls activate the
way they always have. An unpackaged host would have had to fall back to
DllGetActivationFactory probing.Application entry with
EntryPoint="Windows.FullTrustApplication", which requires runFullTrust — see 2.4, which
said the opposite.So the surface that actually forks stays what the file count measures: the view and app-model APIs, and nothing else.
That is a scope limit, and the right one: identity is how the host gets running soonest.
What it would cost to drop later is measured in notes/unpackaged-win32.md, written the same
day — the surface item by item, so the question can be picked up on evidence. Short version: the
mechanical coupling is about three days, because settings are one live line and the access cache
is two files; what actually holds the app to identity is the share target, the app URI handler and
the MSIX update channel, which are product calls rather than porting ones.
One correction to the second bullet below, measured off the generated manifests on disk. The
source Package.appxmanifest declares no activatable classes at all — the packaging targets
generate them — and what they generate differs by flavour:
| Flavour | InProcessServer paths | Telegram.Native* classes |
|---|---|---|
Telegram.csproj (UWP, ARM64 Release) | Telegram.Native.dll, Telegram.Native.Calls.dll, RLottie.dll, Win2D, WebView2 | 60 |
Telegram.Modern.csproj | Win2D, WebView2 | 0 |
Telegram.Win32.csproj | Win2D, WebView2 | 0 |
Win2D and WebView2 are declared because their own NuGet targets do it. The legacy project
declares the native components; neither SDK-style project does, and RLottie.dll drops off
with them, even though all three DLLs sit in the payload.
So "the package manifest declares the activatable classes, exactly as it does today" is not what
happens here — but the conclusion drawn from it is wrong in the other direction, and in a way
that helps. Registration-free WinRT is already what these two flavours run on: the Modern
build works today with zero declarations, so activation is resolving through
DllGetActivationFactory probing rather than through the manifest. C++/WinRT has that fallback
in base.h (line 6131) and CsWinRT has the managed equivalent.
That removes the risk rather than adding one: the Win32 host does not need those entries, because
the mechanism it already depends on never consulted them. It also means the unpackaged case
changes nothing about activation, which is the single biggest de-risking in
unpackaged-win32.md.
2.0 Retire the .NET Native project when .NET 10 has earned it, not before, and not as a precondition for anything here. It is what ships to users; "three flavours is one too many" is a tidiness preference and cannot outrank not breaking the app. Three configurations is the accepted cost of the transition.
This corrects an earlier reading of this item that treated it as a gate on Phase 2. It is not: the islands host ships to nobody and carries no user risk, so it needs time rather than permission.
2.1a The Win32 flavour builds. 2026-08-24, Telegram.exe out of bin\win32\.
This is the headline of the whole phase, and it is much further along than any estimate here.
The whole app - every page, control, view model and service - compiles and links into a plain
desktop process. It has not been run; that needs package identity, which is the next item.
It took four fixes past the first build, and two of them were corrections to this plan rather than new work:
InputListener - forked. See item 0.1, where the detail lives.#region Legacy code activation methods were never UWP-specific. They went into
WindowContext.Uwp.cs during the split because they name IActivatedEventArgs, but that is a
projection both hosts have - a packaged Win32 app is activated with the same args - and the
three Activate(...) overloads are app logic: switch on the authorization state, navigate.
They moved back to shared, which makes the fork smaller. Only UpdateTitleBar /
ClearTitleBar were ever per host, and that region is now called #region Title bar.BootStrapper does not fork as a class. The note said its base type differs and called it
"a bigger rework than the partial split the others take". Wrong twice over: App : Application
works inside an island (gate 1.3a), and of its 827 lines exactly one method needed moving.
BootStrapper.Uwp.cs is 127 lines - OnWindowCreated, the window Activated/Closed
handlers, OnWindowClosed, CreateWindowWrapper - and BootStrapper.Win32.cs is 43. All the
rest, activation included, compiles against either host: they are overrides the framework
simply never raises when there is no Application.Start.private partial WindowContext EnsureWindowContext(IActivatedEventArgs e).
InitializeFrame needed a context and used to build one from Window.Current; now each host
supplies it - UWP reuses what OnWindowCreated made, Win32 creates an IslandWindow. A
partial method with a return type has to be implemented, so the contract is a compile error.OnWindowActivated(Window, bool) became OnWindowActivated(WindowContext, bool) - one
virtual, one override in App.xaml.cs, no behaviour change, and the last Window in a
signature the app overrides. Item 0.2. The UWP handler passes WindowContext.Current, which
is legitimate there: it runs on the window's own thread and a UWP view has one window.Telegram/Telegram.Win32.csproj is Telegram.Modern.csproj with four differences: its own
obj\win32\ / bin\win32\, Exe + our own Program.Main instead of WinExe,
<Compile Remove="**\*.Uwp.cs" />, and no MSIX packaging yet. Telegram.Modern.csproj gained
the mirror image — Remove **\*.Win32.cs and Remove Host\** — and still builds clean.
The host came straight out of the spike into Telegram\Host\: IslandWindow (+ its
non-client half), IslandNative, the Win32 P/Invokes, and a Program.Main that sets DPI
awareness, makes a DispatcherQueue, constructs App before WindowsXamlManager (gate
1.3a) and pumps its own loop. WindowContext.Win32.cs implements the contract: real where the
spike answered it (activation, foreground, key state, Content, Detach), NotImplemented
where it did not, and absent where the member is UWP-only, so its callers are errors.
The first build stopped at eight errors in three files - listed here because the shape of the list is the result, not its length:
InputListener.cs:22,23,28,29 — context.CoreWindow four times. Already item 0.1's
"small class, real rewrite": accelerator keys and pointer-pressed off CoreWindow.BootStrapper.cs:134 — context.CoreWindow == window.CoreWindow, and :144
CreateWindowWrapper(Window). The BootStrapper fork, and both are in window creation.App.xaml.cs:85 — GetNavigationService(Window), and :178 —
Activate(IActivatedEventArgs, INavigationService, AuthorizationState).Nothing on that list was a surprise; every one was already catalogued, and all eight are now fixed. What the build proved even before that is the part worth keeping: the rest of the app needs no change at all to compile against a desktop host. The Phase 0 items that remain are real work, but they are not in the way of a Win32 build any more.
Two things learned standing it up:
DISABLE_XAML_GENERATED_MAIN is not enough here. The constant is set, and the guard is in
the generated App.g.i.cs, but the UWP XAML targets rewrite DefineConstants after the
project body runs, so Application.Start survives and collides with ours (CS0017).
<StartupObject>Telegram.Host.Program</StartupObject> settles it. The spike never hit this
because its own App is trivial and its TFM has no UWP app model attached.WindowContext.Id is an int and an HWND is not. Nothing outside the app reads it now that
ViewService returns WindowContext (item 0.4), so the Win32 half hands out an incrementing
counter — which is also the only thing that means anything on a host with no view ids. 2.1b Packaged, registered, and it starts. 2026-08-24. It does not get a window yet -
it dies in App's constructor - but everything before that works, and the failures on the way
were all worth having.
Startup. Program.Main calls app.Start(AppInstance.GetActivatedEventArgs());
BootStrapper.Win32.cs dispatches to OnLaunched or CallInternalActivated. Real activation
arguments rather than an invented launch, so protocol links, share targets and file activation
all reach the existing paths for free.
Identity. Telegram.Win32.csproj patches the one real manifest, as the Modern project does:
a third identity (38833FF26BA1D.UnigramWin32), EntryPoint="Windows.FullTrustApplication" on
the application and on the two uap5:Extension entries that name the class, and
TargetDeviceFamily to Windows.Desktop. Register with Add-AppxPackage -Register against
publish\AppxManifest.xml, never the build output directory - see net10-port-todo Phase 4.
Remove with Get-AppxPackage 38833FF26BA1D.UnigramWin32 | Remove-AppxPackage.
Four things had to be true before it would even register, and none were obvious:
AppxPackage and EnableMsixTooling are inert without
Microsoft.Windows.SDK.BuildTools[.MSIX]. Telegram.Modern.csproj gets them implicitly;
this project did not, and the symptom is silence - the build succeeds and simply produces no
AppxManifest.xml and no resources.pri. -getProperty:AppxPackageDir,ProjectPriFullPath
coming back empty is the tell. They are explicit PackageReferences here.windows.appService fails
registration with 0x80080204 wanting an EntryPoint it cannot name. Telegram.Stub's bridge is
on the list to retire here anyway (2.5), so it is removed rather than ported.XmlPoke cannot delete nodes or add them, which is what the capability trimming of item
2.4 needs. Package.Win32.xslt does the deletions and the one insertion; the csproj keeps
XmlPoke for the renames. One manifest is still the source of truth.Capabilities is an ordered element - every Capability before the first
DeviceCapability - so runFullTrust has to be inserted between the two groups, not
appended. Appending is 0xC00CE014.Also worth doing before installing anything: the package claims tg:, tonsite:, a
Telegram.exe execution alias and .unigram-theme, on a machine where Telegram is actually
used. They are renamed out of the way (tg-win32, TelegramWin32.exe, ...) so a real link or
file still reaches the shipping app.
Then two runtime failures, in order:
DisableRuntimeMarshalling is on, and Telegram.Host was written with DllImport.
MarshalDirectiveException on the first P/Invoke. The app converted everything to
LibraryImport for exactly this reason, and the new host had to follow: the class is
partial, CharSet is gone (every string here is already an IntPtr from
StringToHGlobalUni), and each bool needs an explicit
[return: MarshalAs(UnmanagedType.Bool)] because a bool is not blittable.
REGDB_E_CLASSNOTREG from CsWinRT is a lie, and cost two wrong theories before the
right one. RLottie.LottieAnimation threw it out of ActivationFactory.ManifestFreeGet, which
reads as a registration problem. It is not: nothing registers these components in either
flavour - both manifests carry 107 ActivatableClassId entries and every one is Win2D or
WebView2, out of their own NuGet snippets. CsWinRT resolves ours by LoadLibrary +
DllGetActivationFactory, so the error only ever means the DLL did not load. Two reasons it
did not, found by diffing the layouts against Modern's rather than by reasoning:
Microsoft.VCLibs.140.00.Debug and
this one did not, so nothing satisfied what the C++/WinRT components link against.
Package.Win32.Final.xslt adds it - and it has to run on the generated manifest, after
_GenerateAppxManifest, because the packaging targets append their own TargetDeviceFamily
and PackageDependency entries afterwards and Dependencies is an ordered element.Directory.Build.props gates TelegramUsesVcpkg on the project
name, so Telegram.Win32 got no ffmpeg, libvlc, dav1d, opus or lz4 and
Telegram.Native.dll could not load either. One name added to that condition.The lesson worth keeping: when a packaged app cannot activate an in-package component, diff its layout and its generated manifest against one that works - the error text points at registration and the fault is almost always a load failure.
Two UWP app model assumptions in shared code, both fixed with the same partial seam as
SetHostContent, both behaviour-identical on UWP:
Application.RequestedTheme fails fast in an island. Fela's rule is that it must be set
before InitializeComponent and cannot change after - and an island host never runs the app
model startup during which it is settable at all, so it is not settable. It is not needed
either: every island root already takes its theme from
NightModeService.GetCalculatedElementTheme in WindowContext.SetContent. Now
protected partial void SetApplicationTheme(ApplicationTheme). Open question - popups
resolve against Application.Resources, so whether the app theme still reaches them on this
host is untested.LaunchActivatedEventArgs.PrelaunchActivated is an InvalidCastException, not a false:
the desktop activation arguments do not implement IPrelaunchActivatedEventArgs. Now
protected partial bool IsPrelaunch(LaunchActivatedEventArgs). InternalLaunch read it
twice; the second read goes through the cached PrelaunchActivated property instead. IT RUNS. 2026-08-24. The real app, as a Win32 process, with a window titled
"Telegram": 1024x720, responding, 50 threads, 420 MB. And it is genuinely alive rather than
merely up - TDLib initialised, creating local.db, langpack and session folder 0
under its own LocalState, with no error reports.
The host is doing its job: enumerating the top-level window shows
Windows.UI.Composition.DesktopWindowContentBridge at 1008x712 and visible, with the gate
1.7 drag bar above it at 1008x32.
But the window is blank white, and that is where this stops. The island exists and is sized, the app is running, nothing renders. The island being the right size means the failure is above it, in content rather than in the host.
InternalLaunch swallows everything. try { InitializeFrame(e); } catch (Exception) { }
with no log and no rethrow - which is why the window came up empty with no trace at all.
Commenting it out is what found each of the following, and it is currently commented out in
the working tree, marked TEMPORARY, do not commit.
Two more of the same family as PrelaunchActivated, both fixed:
Toast.GetSession reads LaunchActivatedEventArgs.TileActivatedInfo, which lives on
ILaunchActivatedEventArgs2 and is not implemented by desktop activation arguments. The
trap: a pattern match on the interface passes anyway, because the projection declares it
on the class, so the QI only fails inside the getter. Catching InvalidCastException is the
only way to ask.WindowContext.Compositor - implemented as Window.Current.Compositor, and then
BootStrapper.Compositor went the same way (Fela's call): the compositor is per thread,
not per window, so it needs neither WindowContext.Current nor a fork. One more Current
use gone.Still empty, and the shape of it is now known. With a debugger attached the Live Visual
Tree shows RootWindow and its Frame - so content is set and the tree is live - but the
Frame has no visual child, while the logs say it navigated to the auth page. Re-running the
island Layout() when content is assigned changed nothing (the captured window is identical
byte for byte), which weakens the zero-size theory. The open question is why a Frame that
reports a successful navigation has no child: a page that threw while materialising would look
exactly like this and, like everything else today, leave no trace.
BackdropMaterial was the first guess and it is not the cause - Fela's, and worth testing
because WinUI 2 applies it to the root page background. Putting it behind
partial void SetBackdropMaterial(WindowControl) and doing nothing on Win32 changed the
captured window not at all, byte for byte. The seam is kept anyway on its own merits: the
Win32 host asks DWM for its backdrop (gate 1.10), so the two are alternatives, not layers.
Still to probe, in order: whether WindowContext.SetContent runs at all on this path, whether
SetHostContent reaches DesktopWindowXamlSource.Content, and whether the tree measures. A
UI Automation dump of the window would separate "no content" from "content that renders
invisibly" in one step and is the cheapest thing to try first.
2.1d Building and running it. Debug to compile-check, Release to judge anything:
MSBuild Telegram\Telegram.Win32.csproj -t:Publish -restore -p:Configuration=Release ^
-p:Platform=x64 -p:RuntimeIdentifier=win-x64 -p:SelfContained=true [-p:PublishAot=false]
-restore. A RID-less build of this project resolves no package
assets at all: Telegram.Native, Rg.DiffUtils, everything vanishes and the compiler reports
thousands of errors that look like a broken projection and are not.vswhere.exe must be on PATH for PublishAot, or ILC compiles and the native link step
dies with MSB3073 'vswhere.exe' is not recognized, looking for link.exe. Prepend
%ProgramFiles(x86)%\Microsoft Visual Studio\Installer. Already recorded for the msixbundle
recipe and still missed here.Telegram.exe
with no Telegram.dll and no System.Private.CoreLib.dll beside it. A failed link leaves the
previous non-AOT layout in place, which runs fine and is not what was asked for.Remove-AppxPackage deletes LocalState, which
is the TDLib database and the login.2.1c First impressions of the running app, 2026-08-24. What running it actually found, none of it visible from a build:
An exception storm, not a slow build. SetPointerCursor threw NotImplementedException
from FormattedTextBlock.OnPointerMoved - once per pointer move over message text - and each
one unwound through the WinRT ABI into WatchDog, which wrote a crash report to disk.
Three reports in forty seconds of hovering. The lesson: a stub that throws on a
pointer-rate path is not a stub, it is a performance bug. The implementation that replaced
it - LoadCursorW/SetCursor - was itself wrong and is gone; see 2.1g.
And then the rest of it was the JIT. This flavour needs NativeAOT to feel right. Measured
across three builds of the same sources: Debug laggy, Release without AOT still laggy, and
Release with PublishAot smooth - Fela: "The AOT build no longer lags, this is great." So the
exception storm above was real and separate, and optimised IL on CoreCLR was not enough on its
own; what closed the gap was the native image. Worth carrying beyond this flavour, because it
says the same about the .NET 10 port generally: CoreCLR JIT does not carry this app's UI.
The drag bar was only laid out from WM_SIZE, so the caption did nothing until the window
was minimized and restored once. Create lays it out now - though that alone did not fix it,
so the cause is not only this.
Minimize sized the island to 0x0, because WM_SIZE reports a 0x0 client rect and Layout
applied it faithfully; restoring then animated the whole tree back. SIZE_MINIMIZED is
skipped now.
The drag bar was registered in the same map as its parent, so IslandWindow.All held every
window twice: the message loop pre-translated each message once per entry, an interop call
doubled on the hottest path in the process, and Windows.Count could never reach zero, so
closing the last window would never have quit. They have their own map now.
Acrylic does not work, and it is the platform rather than us. Flyouts and context menus
fall back to opaque. Fela recalled it as an XAML Islands limitation and it is:
CommunityToolkit/Microsoft.Toolkit.Win32#160 reports acrylic rendering opaque - black, there -
inside WindowsXamlHost no matter how transparent the host window is made
(WindowStyle=None, AllowsTransparency=True). No resolution was ever posted and the repo was
archived in August 2023. It fits the mechanism: in-app acrylic consults
CompositionCapabilities.GetForCurrentView(), which is per view, and host backdrop acrylic
wants a real CoreWindow.
And then it got better. microsoft/microsoft-ui-xaml#2355 draws the distinction that
matters: Windows.UI.Xaml.Media.AcrylicBrush works in islands, Microsoft.UI.Xaml.Media's
does not - every XamlCompositionBrushBase brush from WinUI 2 falls back to its
FallbackColor there. Closed as not planned, so it will not be fixed upstream.
That fits this app precisely: the flyout and menu styles come from WinUI 2's
XamlControlsResources, so every acrylic in them is the broken one. It also suggests a
workaround rather than a redesign - override the WinUI 2 acrylic resource keys with system
Windows.UI.Xaml.Media.AcrylicBrush instances, in the Win32 flavour only. Popups resolve
against Application.Resources (gate 1.11), which is where such a dictionary would go.
Not the DWM-private route of selastingeorge/Win32-Acrylic-Effect, which Fela raised: that is window backdrop acrylic with no XAML involvement, so it cannot reach a flyout inside the island - and it says of itself that it redraws only on activation, which a chat window would show as a stale or flat menu. Gate 1.10's Mica is already the supported answer for the backdrop.
Gate 1.12 passes, 2026-08-24. The spike puts a Windows.UI.Xaml.Media.AcrylicBrush on a
Border with a deliberately hideous magenta FallbackColor, and it renders as tinted
translucent acrylic - so the system brush works inside an island. The probe beside it reports
CompositionCapabilities.GetForCurrentView() returning AreEffectsSupported True,
AreEffectsFast True, which also kills the theory that a failing capability check drives the
fallback: capabilities are fine, and it is Microsoft.UI.Xaml's own implementation that
refuses, exactly as #2355 says.
So the workaround is real. What remains is app work rather than research: find which
resource keys the flyout and menu styles actually consume - XamlControlsResources defines
them, so the list is finite - and merge a Win32-only dictionary that redefines those keys as
system Windows.UI.Xaml.Media.AcrylicBrush instances. Popups resolve against
Application.Resources (gate 1.11), so that is where it goes. Untested risk: WinUI 2 styles
may reference the brushes with {ThemeResource} by key, in which case an override lands
cleanly, or construct them inline, in which case the style has to be replaced too.
2.1f Two host differences that only show at runtime, 2026-08-24.
system32 as a rule. So every
relative path in the app and in its native libraries resolves against the wrong place.
MicroTeX is what surfaced it - RES_BASE is the literal "res", so RichMathSurface
looked its fonts up as resonts\... and found nothing, while the files sat correctly in
the layout. Program.Main now does Directory.SetCurrentDirectory(AppContext.BaseDirectory)
before anything else. Worth remembering as a class of bug rather than one bug: anything
relative is suspect on this host.InputSite - the Windows.UI.Input.InputSite.WindowClass child - so WM_XBUTTONDOWN is not
in the queue to filter and InputListener's back/forward gesture never fired. It now comes off
a routed PointerPressed with handledEventsToo on the window root, attached from
SetHostContent. That is the opposite choice to the keyboard, and deliberately: the tree
handles neither XButton, so a routed handler still sees them, and none of the keyboard's
reasons for needing the message loop - SystemKeyDown, a focused control swallowing the key,
focus inside a WebView2 - apply to a mouse button nothing consumes. 2.1g The stub CoreWindow is a real API surface inside an island, not just a handle.
Established 2026-08-24, after SetCursor failed and Terminal's source explained why.
The starting assumption was that CoreWindow is the UWP app model and therefore absent here,
so anything reading it had to be rewritten against Win32. That is wrong twice over: the stub
exists, and for input state it is the only thing that answers correctly, because XAML's input
site inside the island talks to it rather than to the top-level window.
SetCursor. The island - strictly its InputSite child -
is the window under the pointer, and it answers WM_SETCURSOR itself, so a cursor set on the
top-level window is overwritten on the next mouse move. What works is exactly what the UWP
half does: CoreWindow.GetForCurrentThread().PointerCursor. SetPointerCursor is therefore
shared code again, not forked - it took a fork and a fold-back to learn that.microsoft/terminal main, 2026-08-24 - CoreWindow::GetForCurrentThread().GetKeyState(...)
in TermControl.cpp:2969 (_GetPressedModifierKeys), TerminalPage.cpp:1449,1711,1925,3834,
CommandPalette.cpp:258,421, SearchBoxControl.cpp:261, KeyChordListener.cpp,
SuggestionsControl.cpp. Terminal has no UWP flavour left - OnLaunched is empty and says so -
so every one of those runs in an island.TerminalPage.cpp:6143, CoreWindow::GetForCurrentThread().PointerPosition(),
reading where a tab was dropped outside the tab view.What this closes: item 0.6's four CoreWindow shapes do not all need Win32 twins.
GetKeyState, PointerPosition and PointerCursor can stay shared and keep reading the
CoreWindow; the table above listing GetAsyncKeyState as InputListener's replacement is a
fallback, not a requirement. What genuinely does not survive is the event surface -
CharacterReceived, KeyDown, SizeChanged, Activated on the CoreWindow - because nothing
posts to the stub. That is the same split gate 1.13 found in ContentDialog: state reads
through, events do not.
A lifetime trap that comes with it, from WindowEmperor.cpp:275-295. XAML creates the
CoreWindow implicitly, parented to the first island on the thread. Destroying that island
destroys the CoreWindow, and it cannot be recreated - Terminal's comment calls it a WinUI
bug. Terminal works around it by reparenting the CoreWindow to its own hidden initial window
as soon as the first island exists. We do not, and our windows come and go: on a thread hosting
more than one, closing the first would take the cursor and key state down with it for every
window left. Terminal's second reason applies to us as well - on Windows 10 the CoreWindow
shows on the taskbar as a visible window until it is reparented.
Not a bug we have hit, because chat windows own their thread and the main window is the first on
its own. Worth doing before that stops being true. CoreWindowBridge.Resolve already finds the
stub by class name, so the reparent is three lines - though it caches, and would need to stop
caching or be told, if the stub ever moves.
2.1i Closing the last window crashed instead of exiting, 2026-08-24. Found by Fela the first time the main window had a close button to press - until 1.7a there was none, so this path had never been taken.
The log named it without a dump: the last app-side line was
[FormattedTextBlock.cs][ReleaseNative] released 11 block(s), then nothing, and no crash report -
a native fault after managed teardown. That is the crash WindowContext's shutdown drain was
written for: this view's XamlDirect RCWs are context bound, and releasing one from the finalizer
thread once the XAML core is gone faults on a null CCoreServices. The drain hangs off
DispatcherQueue.ShutdownStarting - and nothing on this host ever shuts the queue down, so
it never runs.
Fixed the way Terminal fixes it: TerminateProcess after the message loop rather than unwinding.
Its comment is the justification - "There's a mysterious crash in XAML on Windows 10 if you just
let _app get destroyed (GH#15410)", plus every UI thread having to be gone before the main
thread returns, and std::exit being no good because ExitProcess still runs the teardown that
faults. The flagship islands app cannot exit cleanly either, which is worth knowing before
anyone spends a day trying to.
Left open: running the drain properly on this host - shut the DispatcherQueue down after the
loop, pump until it completes, then terminate. That would make the release ordering right rather
than merely unreachable. Bounded pumping is the fiddly part: after WM_QUIT a plain GetMessage
blocks, so it needs PeekMessage with a deadline.
2.1j The caption has no system menu. Fela, 2026-08-24. Right-clicking the draggable area does nothing, where a real caption opens Restore / Move / Size / Minimize / Maximize / Close. Alt+Space is the same menu and is presumably just as dead.
Why it is missing rather than broken. The drag bar does forward WM_NCRBUTTONUP to the
parent for HTCAPTION, and the parent's DefWindowProc answers it by sending WM_CONTEXTMENU -
which it then hit-tests against the parent itself. After WM_NCCALCSIZE the parent's non-client
area is only the borders, so the point comes back HTCLIENT and no menu is shown. Forwarding
more messages will not fix it; the menu has to be raised by hand.
Terminal's recipe, IslandWindow::OpenSystemMenu, called from its own WM_NCRBUTTONUP:
GetSystemMenu(hwnd, FALSE) for the menu.GetWindowPlacement().showCmd == SW_SHOWMAXIMIZED:
SC_RESTORE only when maximized, SC_MOVE/SC_SIZE/SC_MAXIMIZE only when not,
SC_MINIMIZE and SC_CLOSE always.SetMenuDefaultItem(menu, UINT_MAX, FALSE) so nothing is bold.TrackPopupMenu(menu, TPM_RETURNCMD, x, y, 0, hwnd, nullptr), then
PostMessage(hwnd, WM_SYSCOMMAND, ret, 0) - return the command and post it rather than letting
the menu dispatch it.Worth tying to 1.7a while writing it: a window that asked for CaptionButtons.Close cannot
minimize or maximize, so those items should be disabled for it too, not only when maximized.
2.1e Re-activation is unhandled, and it is the first real design gap. Found 2026-08-24 by the call window crashing. Chats, IV, the text editor and web apps all open in their own windows correctly; a call fail-fasts, and the app's own log says why:
[150,694] VoipGroupCall InitializeSystemCallAsync ResourcesNotAvailable
[150,694] VoipGroupCall CreateWindow Waiting for window creation
[150,734] BootStrapper OnLaunched Launch <- a SECOND launch, same process
[150,734] InternalLaunch Previous: NotRunning
[150,734] BootStrapper.Win32 EnsureWindowContext Creating the window context
[150,775] NavigationService Navigate Page: MainPage <- a whole second app UI
[150,833] GroupCallWindow ..ctor
OnLaunched ran a second time, 55 seconds into the process. App is a real Application
the system can still call, and nothing on this host expects that: Program.Main reads
AppInstance.GetActivatedEventArgs() exactly once and drives Start itself.
Two faults, in order of severity:
EnsureWindowContext logged "Creating
the window context", so WindowContext.Current - [ThreadStatic] - was null, and it built an
island on a thread with no WindowsXamlManager and no DispatcherQueue. That is a fail-fast,
and frame 0 in Telegram.Native.dll fits. This is exactly the shape item 0.10 warns about,
and the first time Current being thread-static has actually cost something.And the real cause, found 2026-08-24: the window was being built on the wrong thread.
Fela's observation is what cracked it - "the same OpenAsync to open a secondary chat window
doesn't trigger the same". Same path, same DesktopWindowXamlSource, different caller
thread: a chat window is opened from a click, and a call window from a tgcalls or TDLib
callback. UWP hid the difference, because OpenAsyncInternal enqueued everything onto the new
view's dispatcher no matter who called; the Win32 half built the island inline, on whatever
thread arrived. An island on a thread with no WindowsXamlManager and no DispatcherQueue
explains the spurious launch, the wrong thread, and the fail-fast in Telegram.Native together.
ViewService.Win32 now runs both creation paths through OnUIThread, which dispatches to
WindowContext.Main.Dispatcher when the caller has no thread access, and runs inline when it
does. The chat-reuse path dispatches its Activate onto that window's own dispatcher too.
Two guards remain, and they are guards rather than the cure:
ResolveWindowContext answers with Main rather than Current, so nothing builds a window
off-thread even if something else calls in.ShouldHandleLaunch drops framework-delivered launches on this host. Needed regardless:
WindowsXamlManager.InitializeForCurrentThread() raises OnLaunched by itself, which
Program.Main calls on the line before it drives its own - so every startup saw two.How Terminal does it, checked 2026-08-24 before deciding. TerminalApp::App::OnLaunched is
empty, with the comment "We used to support a pure UWP version of the Terminal. This method
was only ever used to do UWP-specific setup". The framework raises it and Terminal ignores it;
startup lives in WindowEmperor/AppHost. So the principle is: do not put startup in
OnLaunched on this host. Ours is in OnLaunched - it is BootStrapper's - so the equivalent
is to call it from Program.Main and suppress the framework's, which is what
ShouldHandleLaunch does.
Two divergences from Terminal worth knowing:
App constructor (App::Initialize). Doing that here
would raise OnLaunched during construction, which is worse than our order, where App
already exists by the time it fires.DispatcherQueue, and fail fast if one exists - a pre-existing
queue is how they detect running under the UWP app model, and
WindowsXamlManager::InitializeForCurrentThread makes one itself. Program.Main here creates
a DispatcherQueueController first, which gate 1.2a concluded was required. Both work; the
contract differs, and it may bear on when the launch fires.Resolved by share target, 2026-08-24 - Fela's example, and it decides it. Ignoring
framework activations is not an option: when the app is not running Program.Main reads the
args from AppInstance and everything works, but when it is running - which is most of the
time - the activation only ever arrives through the framework callback. Suppressing that would
make share, tg: links and toast taps silently do nothing whenever Telegram is open. So:
marshal onto the UI thread's dispatcher, do not suppress.
And share target exposes a second problem, which is worse than the crash because it looks like
it works. On UWP the system creates a separate view for a share: WindowContext.Current
there is a new context, CreateRootElement returns a ShareWindow into it, and the main window
is never touched. App.xaml.cs depends on exactly that shape -
WindowContext.Current.Content is ShareWindow sharePage. Nothing creates that view on Win32, so
the current Current ?? Main fallback would hand the share to the main window and replace
the user's chat list with the share UI.
So EnsureWindowContext is asking the wrong question. Not "is the app already up?" but "which
window does this activation belong to?":
ShareTargetActivatedEventArgs -> an open share window if there is one, else a new
island window. That is what UWP's separate view gave for free.Reachable today: item 2.4 kept the windows.shareTarget extension in the Win32 manifest, so a
share into this flavour will find it.
2.1 Grow Telegram.Stub into the host process rather than creating a new one — it is
already .NET 10, already Win32, already owns the tray and passkeys.
2.2 Desktop IViewService / WindowContext implementations behind the Phase 0 seams.
WindowContext split into shared + WindowContext.Uwp.cs, 2026-08-24. 1376 lines
became 705 shared and 753 UWP. Pure code motion apart from three extracted seams; builds
clean. Telegram.csproj needed the new file added by hand — it lists every file.
What the file name means, because it is easy to read wrong: .Uwp.cs is this host's
implementation, not "UWP-only". IsActive, IsForeground, SetPointerCursor, Bounds,
Title, Compositor, PointerPosition, TryResizeView, the full-screen calls, SetTitleBar
and the four static key-state methods all keep their signatures and all get a Win32 twin. No
call site changes, and no member is marked partial: only one host file is ever in a build,
so each simply defines the member itself. That is the contract — shared code reading
IsActive will not compile unless the host half supplies it.
partial is only for the other direction — the two places shared code has to call into
the host. Declared in WindowContext.cs, implemented in the host half:
partial void SetHostContent(UIElement content); // SetContent's _window.Content =
partial void SetScreenCaptureEnabled(bool enabled); // the two ApplicationView lines
plus private void Detach(), extracted the other way: the host-agnostic half of what the UWP
Closed handler did inline — drop the XamlRoot mapping, suspend and clear the navigation
services, null the content. Each host calls it from its own close path.
WindowContext.Current is the one member with no Win32 twin — Fela, 2026-08-24. It went
to the UWP half for that reason rather than because it names a UWP type: a thread-static
"the window on this thread" answers something only while a thread hosts exactly one window,
which gate 1.8a already showed islands do not guarantee. This is item 0.10, and it is now a
hard requirement rather than a preference. OnShutdownCompleted went with it, being the
per-thread teardown that only means per-window under the same assumption; the shutdown
drain above it stays shared, since DispatcherQueue exists in both hosts.
2.3 The native components need no rebuilding — closed 2026-08-24 without spiking it.
Fela has already run Telegram.Native in a WinUI 3 app. WINAPI_FAMILY_APP restricts which
Win32 APIs the C++ may call, and every one of those exists on desktop too, so the subset
never was the risk; activation was, and package identity answers it. 2.6 stays open as
tidiness rather than as work anything waits on.
2.4 Manifest for the Win32 flavour only. runFullTrust stays — a packaged
Win32 app is declared with EntryPoint="Windows.FullTrustApplication" and cannot run without
it. What goes is the UWP app model around it: windows.fullTrustProcess (the extension a UWP
app uses to launch a full-trust companion — that companion becomes the host), confirmAppClose,
oneProcessVoIP, packageManagement, picturesLibrary, removableStorage. Keep the
extensions. The UWP manifest is untouched.
2.5 Retire Telegram.Stub's IPC, app service and loopback exemption on the Win32
flavour. They stay for as long as the UWP flavour ships.
2.5b CameraCaptureUI is not supported on this host. Fela, 2026-08-24. It is a UWP app
model API and there is no desktop equivalent in the box.
There is a port to steal from if it turns out to matter:
microsoft/WindowsAppSDK dev/Interop/CameraCaptureUI/CameraCaptureUI/CameraCaptureUI.cpp is
WinAppSDK's own reimplementation for desktop apps. Fela's own read is that it is unclear anyone
uses the feature - so the first question is whether to reimplement it or drop the entry point on
this flavour, and that is worth answering before writing anything.
2.5a Toast actions fork — Toast.RegisterBackgroundTasks throws on Win32. Found
2026-08-24. Toast.Register builds an in-process background task (a BackgroundTaskBuilder
with no entry point, triggered by ToastNotificationActionTrigger), and hosting one is a UWP
app model feature: a full trust packaged application has no in-process background task host, the
same reason windows.appService had to go from the manifest in 2.4.
It does not block startup - RegisterBackgroundTasks is wrapped in catch { } and Register
has its own - so the only symptom is silent: tapping a notification action does not reach the
app. Worth knowing before someone spends an afternoon on why replies from a toast do nothing.
The desktop replacement is the COM activator: a windows.toastNotificationActivation extension
naming a CLSID, and the activation arriving as ToastNotificationActivatedEventArgs - which
Toast.GetSession already handles, and which Program.Main already feeds in from
AppInstance.GetActivatedEventArgs(). So the parsing side is done; what forks is only the
registration.
2.7 Close TDLib on the way out - on both hosts. Fela, 2026-08-24, on finding that the app has never done it. Not an islands problem, and the fix is not islands-specific either; it is here because this is where it surfaced and where the exit path now exists.
What is true today. Nothing sends close to TDLib, on either flavour. The one place UWP
could have is commented out - App.OnSuspendingAsync:
//await Task.WhenAll(LifetimeService.Current.ResolveAll<IClientService>().Select(x => x.CloseAsync()));
as is Session.cs's //ClientService.Close(true);. The only live callers of
ClientService.Close(bool) terminate other sessions from the sessions settings page. So the
process simply dies with the database open, and TDLib recovers from its binlog on next start.
That is what the binlog is for and no data is lost by it, but the sqlite database is never
closed cleanly and every start pays a replay it did not have to.
2.1i's TerminateProcess did not introduce this - it inherited it - but it does make the Win32
flavour the first host with a real "we are exiting" moment to hang a proper close on.
The two hosts need different answers, which is the interesting part.
Close() every LifetimeService.Current.Items
session, wait for each to reach AuthorizationStateClosed, bounded at a couple of seconds,
then terminate regardless. It needs no message pumping - unlike 2.1i's drain, TDLib's
updates arrive on Client.Run's own thread, so a plain WaitHandle will do. What is missing
is something on ClientService that signals the close completed; today nothing observes it.Resuming - and paying that reopen every time the
app comes back. The deferral budget is about five seconds, which is enough for a close, but
whether the trade is worth making is a product call rather than a technical one.Open question worth settling first: how much does a clean close actually save at startup? If a binlog replay of a normal session costs single-digit milliseconds, the UWP half is not worth its risk and only Win32 should do it.
2.6 Re-point the native dependency builds off the WINAPI_FAMILY_APP subset.
Microsoft.UI.Xaml island alongside a Windows.UI.Xaml one in the same
process. That is what makes a per-surface WinUI 3 migration possible at all.XamlDirect.VoipCallCoordinator?WM_APPCOMMAND instead.UseUwp gate the XAML projection to the UWP app model?UseUwp
supplies projections and the XAML toolchain; the output is still an ordinary Win32 exe.Windows.UI.Xaml supported? The same roadmap question
net10-benefits-and-winui3.md ends on, and it still outweighs every technical item here —
though this path is strictly less exposed to it than staying on UWP, because the process is
no longer tied to the framework.