From 1baa9cf59bbd108bce2e596fcdb4eaa9048d0a60 Mon Sep 17 00:00:00 2001 From: Martin Zikmund Date: Thu, 6 Aug 2026 13:46:51 +0200 Subject: [PATCH 1/5] build(uno): pin Uno.Sdk and make one shared-source line Uno-nullable-clean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for the Uno Platform port. This is the entire footprint the port has on existing files — everything else it adds is new. - global.json gains an Uno.Sdk msbuild-sdks pin. Inert for the Windows build: no Windows project declares Sdk="Uno.Sdk". It is required at the *solution* level because SDK resolution for Reactor.Uno.slnx reads the repo-root global.json; the per-project ones are never consulted for a root slnx build. - Reconciler.Mount.cs returns `default` instead of `null` from a generic helper. T is constrained to a reference type, so this is behaviour-identical on Windows; it just satisfies the nullable analysis on the Uno build. Not a conditional — the port compiles the shared source with ZERO #if REACTOR_UNO. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017rs9N4ZH4ss5tDtBwBN1Go --- global.json | 3 +++ src/Reactor/Core/Reconciler.Mount.cs | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/global.json b/global.json index f72210cac..1236563f4 100644 --- a/global.json +++ b/global.json @@ -2,5 +2,8 @@ "sdk": { "version": "10.0.100", "rollForward": "latestFeature" + }, + "msbuild-sdks": { + "Uno.Sdk": "6.7.0-dev.117" } } \ No newline at end of file diff --git a/src/Reactor/Core/Reconciler.Mount.cs b/src/Reactor/Core/Reconciler.Mount.cs index 158b59c42..6e0f4c274 100644 --- a/src/Reactor/Core/Reconciler.Mount.cs +++ b/src/Reactor/Core/Reconciler.Mount.cs @@ -380,7 +380,10 @@ internal static bool EnsureNumberBoxImmediateTextBoxWiring(WinUI.NumberBox box) var nested = FindDescendant(child); if (nested is not null) return nested; } - return null; + // `default` (== null; T is constrained to a reference type) rather than + // `null` to satisfy Uno's nullable analysis on the shared build; identical + // behavior on Windows. + return default; } internal static void HandleNumberBoxImmediateTextChanged(WinUI.NumberBox box, string text) From 806c4ba8794ff545807d6d5e1d6370ba70118cc3 Mon Sep 17 00:00:00 2001 From: Martin Zikmund Date: Thu, 6 Aug 2026 13:47:08 +0200 Subject: [PATCH 2/5] =?UTF-8?q?feat(uno):=20add=20Reactor.Uno=20=E2=80=94?= =?UTF-8?q?=20the=20Uno=20Platform=20(Skia)=20port=20of=20the=20framework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recompiles the Reactor framework against Uno's Microsoft.UI.Xaml so the same Reactor C# runs on desktop (Win32/X11/macOS/FrameBuffer), WebAssembly, Android and iOS. Ships as assembly `Reactor`, namespace `Microsoft.UI.Reactor`, so consumer code is unchanged. Fundamentally a retargeting, not a rewrite: the reconciler, hooks, DSL, layout engine, charting, markdown and data grid are source-shared verbatim from src/Reactor and src/Reactor.Advanced. Only the Windows-only hosting stack (windowing/shell/DWM/persistence/devtools) and Docking/Win2D are excluded, and a slim Uno hosting layer replaces them: ReactorApp (Run/RunAsync/CreateApplication), ReactorHost (render loop), ReactorWindow, and multi-window support. Notable behaviour this layer gets right rather than stubs: - Real secondary windows on every desktop head, each with its own host and state. - Per-window DPI from XamlRoot.RasterizationScale, tracking live changes. - Window closing guards via AppWindow.Closing; file/folder pickers; declarative caption height via AppWindowTitleBar.PreferredHeightOption. - Hot Reload: the shared HotReloadService's [assembly: MetadataUpdateHandler] is active here too, and the render loop implements the full spec 049 pass — hook-cell migration and HookOrderException recovery, not just a re-render. - A themed window root, because a Skia window paints nothing of its own. - DIP->physical conversion for AppWindow.Resize, which takes pixels. Source-share drift is the main hazard of this design and is guarded: globbed roots and the cherry-picked Hosting/ folder are both diffed against declared lists at build time, so an upstream move fails the build instead of silently dropping a subsystem. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017rs9N4ZH4ss5tDtBwBN1Go --- src/Reactor.Uno/Directory.Build.props | 16 + src/Reactor.Uno/Directory.Packages.props | 11 + src/Reactor.Uno/GlobalUsings.cs | 16 + src/Reactor.Uno/Hosting/ReactorApp.cs | 387 ++++++++++++++ src/Reactor.Uno/Hosting/ReactorHost.cs | 535 +++++++++++++++++++ src/Reactor.Uno/Hosting/UnoBootstrap.cs | 105 ++++ src/Reactor.Uno/Hosting/Windowing.cs | 592 +++++++++++++++++++++ src/Reactor.Uno/README.md | 339 ++++++++++++ src/Reactor.Uno/Reactor.Uno.csproj | 225 ++++++++ src/Reactor.Uno/WinRTShims.cs | 35 ++ src/Reactor.Uno/global.json | 10 + src/Reactor.Uno/nuget.config | 13 + src/Reactor.Uno/reactor-uno-explainer.html | 301 +++++++++++ 13 files changed, 2585 insertions(+) create mode 100644 src/Reactor.Uno/Directory.Build.props create mode 100644 src/Reactor.Uno/Directory.Packages.props create mode 100644 src/Reactor.Uno/GlobalUsings.cs create mode 100644 src/Reactor.Uno/Hosting/ReactorApp.cs create mode 100644 src/Reactor.Uno/Hosting/ReactorHost.cs create mode 100644 src/Reactor.Uno/Hosting/UnoBootstrap.cs create mode 100644 src/Reactor.Uno/Hosting/Windowing.cs create mode 100644 src/Reactor.Uno/README.md create mode 100644 src/Reactor.Uno/Reactor.Uno.csproj create mode 100644 src/Reactor.Uno/WinRTShims.cs create mode 100644 src/Reactor.Uno/global.json create mode 100644 src/Reactor.Uno/nuget.config create mode 100644 src/Reactor.Uno/reactor-uno-explainer.html diff --git a/src/Reactor.Uno/Directory.Build.props b/src/Reactor.Uno/Directory.Build.props new file mode 100644 index 000000000..e264d5dca --- /dev/null +++ b/src/Reactor.Uno/Directory.Build.props @@ -0,0 +1,16 @@ + + + + latest + + $(NoWarn);CS1591;CS1574;CS1580;CS1735 + + diff --git a/src/Reactor.Uno/Directory.Packages.props b/src/Reactor.Uno/Directory.Packages.props new file mode 100644 index 000000000..22dae8cb0 --- /dev/null +++ b/src/Reactor.Uno/Directory.Packages.props @@ -0,0 +1,11 @@ + + + + false + + diff --git a/src/Reactor.Uno/GlobalUsings.cs b/src/Reactor.Uno/GlobalUsings.cs new file mode 100644 index 000000000..a7d537d8c --- /dev/null +++ b/src/Reactor.Uno/GlobalUsings.cs @@ -0,0 +1,16 @@ +// Disambiguation aliases for the Uno port. +// +// Uno defines ElementFactoryGetArgs / ElementFactoryRecycleArgs in BOTH +// Microsoft.UI.Xaml and Microsoft.UI.Xaml.Controls, which makes the unqualified +// name in Core/ElementFactory.cs (it imports both namespaces) ambiguous. On the +// Windows build the type only exists in Microsoft.UI.Xaml, so pin the alias to +// that namespace — matching IElementFactory's member signature. +global using ElementFactoryGetArgs = Microsoft.UI.Xaml.ElementFactoryGetArgs; +global using ElementFactoryRecycleArgs = Microsoft.UI.Xaml.ElementFactoryRecycleArgs; + +// Uno's implicit `Microsoft.UI.Xaml.Controls` global using cannot be removed via +// (Uno injects it from the SDK targets). The only bare-`SelectionMode` +// *type* reference in shared source is DataGridFactories.cs, which wants Reactor's +// enum; the WinUI list controls use control-specific enums (ListViewSelectionMode, +// …), so pinning the bare name to Reactor's type is safe project-wide. +global using SelectionMode = Microsoft.UI.Reactor.Controls.SelectionMode; diff --git a/src/Reactor.Uno/Hosting/ReactorApp.cs b/src/Reactor.Uno/Hosting/ReactorApp.cs new file mode 100644 index 000000000..37253322c --- /dev/null +++ b/src/Reactor.Uno/Hosting/ReactorApp.cs @@ -0,0 +1,387 @@ +// Uno hosting entry point for Reactor. +// +// Replaces the Windows framework's src/Reactor/Hosting/ReactorApp.cs (which is +// built on Application.Start + Win32/DWM/shell P/Invoke). Here, startup goes +// through Uno's UnoPlatformHostBuilder (Uno 6 unified Skia hosting) and a +// code-only Application subclass. Only the static surface the shared core reads +// (ActiveHostInternal, UIDispatcher, …) plus the public Run/OpenWindow entry points +// are provided. Multi-window is real on the desktop heads (every window gets its own +// ReactorHost); tray icons and window persistence remain stubs. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Reactor.Hosting; +using Microsoft.UI.Windowing; + +namespace Microsoft.UI.Reactor; + +/// Startup configuration captured before the Uno host bootstraps. +internal sealed record ReactorAppOptions( + Func? RootFactory = null, + Func? RootRenderFunc = null, + Action? Configure = null, + string WindowTitle = "Reactor App", + double? WindowWidth = null, + double? WindowHeight = null, + bool FullScreen = false); + +/// +/// Application entry point for Reactor apps running on Uno Platform Skia targets +/// (desktop + WebAssembly, and theoretically mobile). +/// +public static partial class ReactorApp +{ + private static ReactorAppOptions _options = new(); + internal static ReactorAppOptions Options + { + get => Volatile.Read(ref _options); + set => Volatile.Write(ref _options, value); + } + + private static ReactorHost? _activeHost; + + /// The host of the active Reactor window (single-window model). + public static ReactorHost? ActiveHost => Volatile.Read(ref _activeHost); + + internal static ReactorHost? ActiveHostInternal + { + get => Volatile.Read(ref _activeHost); + set => Volatile.Write(ref _activeHost, value); + } + + private static DispatcherQueue? _uiDispatcher; + + /// The UI dispatcher captured at launch (null before the window exists). + public static DispatcherQueue? UIDispatcher + { + get => Volatile.Read(ref _uiDispatcher); + internal set => Volatile.Write(ref _uiDispatcher, value); + } + + /// Optional process-wide logger snapshotted by each host at construction. + public static ILogger? AppLogger { get; set; } + + /// + /// Optional process-wide unhandled-exception hook. Return true to mark the + /// exception handled; return false (or leave null) to let it crash. Mirrors + /// the Windows framework's ReactorApp.OnUnhandledException. + /// + public static Func? OnUnhandledException { get; set; } + + /// Devtools are not available in the Uno port. + public static bool DevtoolsEnabled => false; + + // ── window topology (tray icons remain a Windows-shell stub) ── + private static readonly List _windows = new(); + private static readonly List _trayIcons = new(); + + /// Snapshot of open Reactor windows. + public static IReadOnlyList Windows + { + get { lock (_windows) { return _windows.ToArray(); } } + } + + /// The primary (first) window, or null before launch. + public static ReactorWindow? PrimaryWindow { get; internal set; } + + internal static void RegisterWindow(ReactorWindow w) + { + lock (_windows) { if (!_windows.Contains(w)) _windows.Add(w); } + PrimaryWindow ??= w; + } + + internal static void UnregisterWindow(ReactorWindow w) + { + lock (_windows) + { + _windows.Remove(w); + if (ReferenceEquals(PrimaryWindow, w)) + PrimaryWindow = _windows.Count > 0 ? _windows[0] : null; + } + } + + /// Finds an open window by its . + public static ReactorWindow? FindWindow(WindowKey key) + { + lock (_windows) + { + foreach (var w in _windows) + if (w.Spec.Key == key) return w; + } + return null; + } + + /// + /// Opens a window hosting its own Reactor tree. Signature-compatible with the + /// Windows framework's ReactorApp.OpenWindow. + /// + /// + /// Real secondary windows work on every Uno desktop head + /// (X11 / Win32 / macOS / FrameBuffer). Android and iOS do not support secondary + /// windows: Uno throws from the + /// Window constructor, which UseOpenWindow catches and degrades to + /// a null handle rather than failing the render. + /// Must be called on the UI thread. + /// + public static ReactorWindow OpenWindow( + WindowSpec spec, + Func root, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(spec); + ArgumentNullException.ThrowIfNull(root); + return OpenWindowCore(spec, root, renderFunc: null, configure); + } + + /// + /// Opens a window with a render-function root. See the + /// overload for platform support and semantics. + /// + public static ReactorWindow OpenWindow( + WindowSpec spec, + Func render, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(spec); + ArgumentNullException.ThrowIfNull(render); + return OpenWindowCore(spec, rootFactory: null, render, configure); + } + + // Single construction path for every Reactor window — the primary one that + // UnoBootstrap opens at launch and every secondary window opened later. + internal static ReactorWindow OpenWindowCore( + WindowSpec spec, + Func? rootFactory, + Func? renderFunc, + Action? configure) + { + // On Android/iOS this throws InvalidOperationException for anything after + // the first window. Deliberately not caught here: UseOpenWindow handles it. + var native = new Microsoft.UI.Xaml.Window(); + var window = new ReactorWindow(native, spec); + var host = new ReactorHost(native) { OwningWindow = window }; + window.Host = host; + + configure?.Invoke(host); + RegisterWindow(window); + native.Closed += (_, _) => UnregisterWindow(window); + + try + { + if (rootFactory is not null) + host.Mount(rootFactory()); + else if (renderFunc is not null) + host.Mount(renderFunc); + + ApplyChrome(native, spec); + native.Activate(); + // DIP->physical sizing needs a live RasterizationScale, so it can + // only run once the window has a XamlRoot. Attempt it here and let + // ReactorWindow.OnContentAttached retry if that hasn't happened yet. + window.ApplyInitialSize(); + } + catch + { + UnregisterWindow(window); + try { host.Dispose(); } catch { /* best effort */ } + throw; + } + + return window; + } + + // Title + caption height. AppWindow is only partially supported across Skia + // heads, so every step is best-effort. + private static void ApplyChrome(Microsoft.UI.Xaml.Window native, WindowSpec spec) + { + try { native.Title = spec.Title; } catch { /* best effort */ } + + if (spec.TitleBarHeight is { } height) + { + try + { + var titleBar = native.AppWindow?.TitleBar; + if (titleBar is not null) + { + titleBar.PreferredHeightOption = height switch + { + WindowTitleBarHeight.Tall => TitleBarHeightOption.Tall, + WindowTitleBarHeight.Collapsed => TitleBarHeightOption.Collapsed, + _ => TitleBarHeightOption.Standard, + }; + } + } + catch { /* caption sizing unsupported on this head */ } + } + } + + /// Tray icons are a Windows shell feature; returns a stub handle. + public static ReactorTrayIcon OpenTrayIcon(TrayIconSpec spec) + { + var icon = new ReactorTrayIcon(spec); + lock (_trayIcons) { _trayIcons.Add(icon); } + return icon; + } + + /// Finds a registered tray-icon stub by key. + public static ReactorTrayIcon? FindTrayIcon(WindowKey key) + { + lock (_trayIcons) + { + foreach (var t in _trayIcons) + if (t.Spec.Key == key) return t; + } + return null; + } + + /// + /// Bulk-registers the built-in control catalog. Factory methods already + /// self-register on first use, so this is only needed for the direct + /// element-record idiom. The Uno port relies on factory self-registration, + /// so this is currently a no-op kept for API parity. + /// + public static void RegisterAllBuiltIns() { /* factories self-register */ } + + // ── public Run entry points ─────────────────────────────────────────── + + /// + /// Launches a Reactor app whose root is a subclass. + /// Blocks until the window closes (desktop). For WebAssembly use + /// . + /// + public static void Run( + string title = "Reactor App", + double? width = null, + double? height = null, + bool fullScreen = false, + Action? configure = null) + where TRoot : Component, new() + { + Options = new ReactorAppOptions( + RootFactory: static () => new TRoot(), + Configure: configure, + WindowTitle: title, + WindowWidth: width, + WindowHeight: height, + FullScreen: fullScreen); + UnoBootstrap.Run(); + } + + /// + /// Launches a Reactor app from a render function (no Component subclass). + /// Blocks until the window closes (desktop). + /// + public static void Run( + string title, + Func rootRender, + double? width = null, + double? height = null, + bool fullScreen = false, + Action? configure = null) + { + Options = new ReactorAppOptions( + RootRenderFunc: rootRender, + Configure: configure, + WindowTitle: title, + WindowWidth: width, + WindowHeight: height, + FullScreen: fullScreen); + UnoBootstrap.Run(); + } + + /// + /// Async launch for WebAssembly (the browser thread cannot block). Mirror of + /// ; await it from a file-based app's top-level statements. + /// + public static Task RunAsync( + string title = "Reactor App", + double? width = null, + double? height = null, + bool fullScreen = false, + Action? configure = null) + where TRoot : Component, new() + { + Options = new ReactorAppOptions( + RootFactory: static () => new TRoot(), + Configure: configure, + WindowTitle: title, + WindowWidth: width, + WindowHeight: height, + FullScreen: fullScreen); + return UnoBootstrap.RunAsync(); + } + + /// Async launch (render function) for WebAssembly. + public static Task RunAsync( + string title, + Func rootRender, + double? width = null, + double? height = null, + bool fullScreen = false, + Action? configure = null) + { + Options = new ReactorAppOptions( + RootRenderFunc: rootRender, + Configure: configure, + WindowTitle: title, + WindowWidth: width, + WindowHeight: height, + FullScreen: fullScreen); + return UnoBootstrap.RunAsync(); + } + + // ── mobile / native-head entry point ────────────────────────────────── + + /// + /// Builds the Uno that hosts + /// , without starting a host builder. + /// + /// + /// Android and iOS do not start from a console Main — the OS + /// owns the entry point (an Activity / AppDelegate) and the + /// native head hands Uno an application factory. + /// therefore cannot be used there; call this from the head instead: + /// + /// public class Application : Microsoft.UI.Xaml.NativeApplication + /// { + /// public Application(IntPtr javaReference, JniHandleOwnership transfer) + /// : base(() => ReactorApp.CreateApplication<CounterApp>("My App"), + /// javaReference, transfer) { } + /// } + /// + /// Everything above the hosting layer — components, hooks, the + /// reconciler — is identical to desktop and WebAssembly. + /// + public static Microsoft.UI.Xaml.Application CreateApplication( + string title = "Reactor App", + Action? configure = null) + where TRoot : Component, new() + { + Options = new ReactorAppOptions( + RootFactory: static () => new TRoot(), + Configure: configure, + WindowTitle: title); + return new ReactorApplication(); + } + + /// + /// Render-function overload of for + /// native heads. + /// + public static Microsoft.UI.Xaml.Application CreateApplication( + string title, + Func rootRender, + Action? configure = null) + { + Options = new ReactorAppOptions( + RootRenderFunc: rootRender, + Configure: configure, + WindowTitle: title); + return new ReactorApplication(); + } +} diff --git a/src/Reactor.Uno/Hosting/ReactorHost.cs b/src/Reactor.Uno/Hosting/ReactorHost.cs new file mode 100644 index 000000000..8222d077e --- /dev/null +++ b/src/Reactor.Uno/Hosting/ReactorHost.cs @@ -0,0 +1,535 @@ +// Slim Reactor render host for Uno Skia targets. +// +// Adapted from src/Reactor/Hosting/ReactorHost.cs, keeping the coalescing render +// loop, the reconcile→Window.Content install, effects flush, perf stats, and the +// charting-activation seam. Dropped (vs. the Windows host): system backdrop, +// dev-overlay wiring, hot-reload state migration, focus-revalidation, and the +// in-render accessibility push — none are needed for the Skia port's core path. + +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Reactor.Animation; +using Microsoft.UI.Reactor.Core; + +namespace Microsoft.UI.Reactor.Hosting; + +/// +/// Hosts a Reactor component tree inside an Uno . Drives the +/// render loop: on state change, re-renders the component and reconciles the +/// virtual tree against the real control tree. +/// +public sealed class ReactorHost : IDisposable +{ + private readonly Window _window; + private readonly Reconciler _reconciler; + private readonly DispatcherQueue _dispatcherQueue; + private readonly ILogger? _logger; + + private Component? _rootComponent; + private Func? _rootRenderFunc; + private RenderContext? _funcContext; + + private Element? _currentTree; + private UIElement? _currentControl; + private int _renderPending; + private volatile bool _isRendering; + private volatile bool _needsRerender; + private FrameworkElement? _themeListenerElement; + private volatile bool _disposed; + + private Curve? _pendingAnimationCurve; + private Microsoft.UI.Reactor.Core.Internal.AmbientAnimation? _pendingAmbientAnimation; + + // ── charting accessibility seam (issue #498) ── + private global::Windows.UI.ViewManagement.AccessibilitySettings? _accessibilitySettings; + private global::Windows.UI.ViewManagement.UISettings? _uiSettings; + private volatile bool _isForcedColors; + private volatile bool _isReducedMotion; + private object? _forcedColorsTheme; + private int _chartingActiveFlag; + private static IChartingHostBridge? s_chartingBridge; + + private readonly Stopwatch _phaseSw = new(); + private double _lastRenderMs; + private RenderStats _stats; + + /// Live render performance snapshot, updated about once per second. + public ref readonly RenderStats Stats => ref _stats; + + /// The underlying reconciler (for RegisterType calls). + public Reconciler Reconciler => _reconciler; + + /// Optional per-render timing callback: (treeBuildMs, reconcileMs, effectsMs). + public Action? OnRenderComplete { get; set; } + + /// The Uno window hosting this Reactor tree. + public Window Window => _window; + + /// The Reactor window wrapper that owns this host. + public ReactorWindow? OwningWindow { get; internal set; } + + /// When set, Reactor renders into this Border instead of Window.Content. + public Microsoft.UI.Xaml.Controls.Border? ContentTarget { get; set; } + + internal Component? RootComponent => _rootComponent; + internal UIElement? CurrentControl => _currentControl; + + public ReactorHost(Window window, ILogger? logger = null) + { + _logger = logger ?? ReactorApp.AppLogger; + _reconciler = new Reconciler(_logger); + _window = window; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + + if (ReactorApp.UIDispatcher is null) + ReactorApp.UIDispatcher = _dispatcherQueue; + ReactorApp.ActiveHostInternal = this; + + try + { + _window.Closed += (_, _) => Dispose(); + } + catch { /* headless / windowless */ } + } + + // ── charting activation seam ── + + internal static void RegisterChartingBridge(IChartingHostBridge bridge) + => s_chartingBridge = bridge; + + internal void EnsureChartingActive() + { + if (Interlocked.CompareExchange(ref _chartingActiveFlag, 1, 0) != 0) + return; + + if (_dispatcherQueue.HasThreadAccess) + InitChartingState(); + else + _dispatcherQueue.TryEnqueue(InitChartingState); + } + + private void InitChartingState() + { + try + { + _accessibilitySettings = new global::Windows.UI.ViewManagement.AccessibilitySettings(); + _isForcedColors = _accessibilitySettings.HighContrast; + if (_isForcedColors) + _forcedColorsTheme = s_chartingBridge?.CaptureForcedColorsTheme(); + } + catch { /* settings unavailable on this head */ } + + try + { + _uiSettings = new global::Windows.UI.ViewManagement.UISettings(); + _isReducedMotion = !_uiSettings.AnimationsEnabled; + } + catch { /* settings unavailable */ } + + PushChartingState(); + } + + private void PushChartingState() + => s_chartingBridge?.PushAccessibilityState(_isForcedColors, _isReducedMotion, _forcedColorsTheme); + + // ── mount + render loop ── + + public void Mount(Component component) + { + _rootComponent = component; + RequestRender(); + } + + public void Mount(Func renderFunc) + { + _rootRenderFunc = renderFunc; + _funcContext = new RenderContext(); + RequestRender(); + } + + internal void RequestRender(bool force = false) + { + if (_disposed) return; + + if (force) + _reconciler.ForceFullRenderPending = true; + + if (AnimationScope.HasScope) + _pendingAnimationCurve = AnimationScope.Current; + + var captured = Microsoft.UI.Reactor.Core.Internal.AnimationAmbient.Current; + if (captured is not null) + _pendingAmbientAnimation = captured; + + if (_isRendering) + { + _needsRerender = true; + return; + } + + if (Interlocked.CompareExchange(ref _renderPending, 1, 0) != 0) + { + _needsRerender = true; + return; + } + + // First render is synchronous on the UI thread so content attaches + // before window.Activate() returns. + if (_currentControl is null && _dispatcherQueue.HasThreadAccess) + { + RenderLoop(); + return; + } + + _dispatcherQueue.TryEnqueue( + RenderPriorityPolicy.PickPriority(Volatile.Read(ref _lastRenderMs)), + RenderLoop); + } + + private void RenderLoop() + { + if (_disposed) return; + + _needsRerender = false; + Render(); + + Interlocked.Exchange(ref _renderPending, 0); + + if (_needsRerender) + { + if (Interlocked.CompareExchange(ref _renderPending, 1, 0) == 0) + _dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, RenderLoop); + } + } + + /// + /// Hot Reload state migration entry point (spec 049 §6). Runs once at the + /// start of a hot-reload render pass, before any component re-renders, and + /// asks every live to value-swap hook cells + /// whose stored type was edited. The root component/function context is not + /// registered with the reconciler, so it is migrated explicitly. Never + /// throws out — a migration failure must not abort the reload render. + /// + private void MigrateHotReloadState() + { + if (!HotReloadService.IsHotReloadLive) return; + + var updatedTypes = HotReloadService.UpdatedTypes; + if (updatedTypes is null || updatedTypes.Count == 0) return; + + try + { + _rootComponent?.Context.MigrateHooksForHotReload(updatedTypes); + _funcContext?.MigrateHooksForHotReload(updatedTypes); + _reconciler.ForEachLiveContext(ctx => ctx.MigrateHooksForHotReload(updatedTypes)); + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Hot reload: state migration pass failed; continuing with re-render"); + } + } + + private void Render() + { + _isRendering = true; + + // Hot Reload (spec 049). HotReloadService is source-shared from the + // Windows framework and its [assembly: MetadataUpdateHandler] is active + // in this assembly too, so `dotnet watch` edits land here identically: + // atomic capture-and-clear gives at-most-once recovery per + // UpdateApplication call. + bool hotReloadRender = HotReloadService.ConsumeUpdatePending(); + + // Open a tree-wide pass so the reconciler can recover hook-order changes + // in non-root children (Reconciler.UpdateComponent reads WithinUpdatePass). + using IDisposable? hotReloadPass = hotReloadRender + ? HotReloadService.BeginUpdatePass() + : null; + + // Value-swap hook cells whose stored type was edited, so adding or + // removing a field on a record held in UseState/UseReducer preserves the + // surviving values instead of resetting to the initializer. + if (hotReloadRender) + MigrateHotReloadState(); + + var prevActiveHost = ReactorApp.ActiveHostInternal; + ReactorApp.ActiveHostInternal = this; + + void RecoverFromHookOrder(HookOrderException ex, RenderContext ctx, string mode) + { + _logger?.LogWarning(ex, + "Hot reload: hook order/type changed — resetting {Mode} state and re-rendering", + mode); + ctx.ResetForHotReload(); + RequestRender(); + } + + try + { + Element? newTree = null; + _phaseSw.Restart(); + + Action rerender = () => RequestRender(); + + if (_rootComponent is not null) + { + _rootComponent.Context.BeginRender(rerender); + try { newTree = _rootComponent.Render(); } + catch (HookOrderException ex) when (hotReloadRender) + { + // An edit that adds/removes/reorders hooks changes the hook + // shape the context was built with. Under hot reload that is + // an expected consequence of the edit, not a user bug: drop + // the hook state and re-mount rather than falling through to + // the error overlay. + RecoverFromHookOrder(ex, _rootComponent.Context, "component"); + return; + } + catch (Exception ex) + { + _logger?.LogError(ex, "Component Render() threw"); + ShowErrorFallback(ex); + return; + } + } + else if (_rootRenderFunc is not null && _funcContext is not null) + { + _funcContext.BeginRender(rerender); + try { newTree = _rootRenderFunc(_funcContext); } + catch (HookOrderException ex) when (hotReloadRender) + { + RecoverFromHookOrder(ex, _funcContext, "function-component"); + return; + } + catch (Exception ex) + { + _logger?.LogError(ex, "Function component threw"); + ShowErrorFallback(ex); + return; + } + } + + double treeBuildMs = _phaseSw.Elapsed.TotalMilliseconds; + if (newTree is null) return; + + _phaseSw.Restart(); + + var capturedCurve = Interlocked.Exchange(ref _pendingAnimationCurve, null); + if (capturedCurve is not null) + AnimationScope.PushScope(capturedCurve); + + var capturedAmbient = Interlocked.Exchange(ref _pendingAmbientAnimation, null); + using var ambientRestore = capturedAmbient is not null + ? new Microsoft.UI.Reactor.Core.Internal.AnimationAmbient.Scope(capturedAmbient) + : default; + + UIElement? newControl; + try + { + newControl = _reconciler.Reconcile(_currentTree, newTree, _currentControl, rerender); + } + finally + { + if (capturedCurve is not null) + AnimationScope.PopScope(); + } + + if (newControl != _currentControl) + { + if (ContentTarget is not null) + { + ContentTarget.Child = newControl; + AttachThemeListener(newControl); + OwningWindow?.OnContentAttached(newControl); + } + else + { + // Mount through a themed root rather than assigning the tree + // to Window.Content directly. A Skia window paints nothing of + // its own, so anything the app leaves transparent — a + // NavigationView pane, the gutters around a smaller root — + // shows through as black. WinUI gets a system-painted window + // background for free; this is the Uno equivalent, and it + // follows the light/dark theme because the brush is resolved + // from the theme dictionary. + var root = EnsureWindowRoot(); + root.Child = newControl; + AttachThemeListener(newControl); + // The XamlRoot (and hence RasterizationScale) only exists once + // content is attached — let the window (re)bind its DPI + // listener against the element that is actually in the tree. + OwningWindow?.OnContentAttached(root); + } + } + + _currentControl = newControl; + _currentTree = newTree; + + _reconciler.FlushConnectedAnimations(); + + double reconcileMs = _phaseSw.Elapsed.TotalMilliseconds; + _phaseSw.Restart(); + + if (_rootComponent is not null) + _rootComponent.Context.FlushEffects(); + else + _funcContext?.FlushEffects(); + + double effectsMs = _phaseSw.Elapsed.TotalMilliseconds; + + Interlocked.Exchange(ref _lastRenderMs, treeBuildMs + reconcileMs + effectsMs); + OnRenderComplete?.Invoke(treeBuildMs, reconcileMs, effectsMs); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Render FAILED"); + ShowErrorFallback(ex); + } + finally + { + _isRendering = false; + ReactorApp.ActiveHostInternal = prevActiveHost; + } + } + + private void AttachThemeListener(UIElement? control) + { + if (_themeListenerElement is not null) + _themeListenerElement.ActualThemeChanged -= OnActualThemeChanged; + + if (control is not FrameworkElement fe) + { + _themeListenerElement = null; + return; + } + + _themeListenerElement = fe; + fe.ActualThemeChanged += OnActualThemeChanged; + } + + private void OnActualThemeChanged(FrameworkElement sender, object args) + { + // The window root's brush was resolved from the theme dictionary at the + // time it was applied, so re-resolve it for the new theme before the + // re-render (a plain Background assignment is not a live ThemeResource). + ApplyWindowRootBackground(); + RequestRender(); + } + + // Root container for the window-hosted case. A Skia window paints nothing of + // its own, so without this every region the app leaves transparent renders + // black. WinUI apps get this for free: a Page carries + // ApplicationPageBackgroundThemeBrush from its template, and the stock Uno + // template sets exactly that on its MainPage. Reactor hands the reconciled + // tree straight to Window.Content, so the host has to supply it. + private Microsoft.UI.Xaml.Controls.Border? _windowRoot; + + private Microsoft.UI.Xaml.Controls.Border EnsureWindowRoot() + { + if (_windowRoot is not null) return _windowRoot; + + // Built from markup rather than `new Border()` so Background is a real + // {ThemeResource} binding. Resolving the brush in code (reading + // Application.Current.Resources) is NOT theme-aware — it returns whichever + // variant the app dictionary happens to hold, which produced a dark + // background under a light theme. + try + { + _windowRoot = (Microsoft.UI.Xaml.Controls.Border)Microsoft.UI.Xaml.Markup.XamlReader.Load( + """ + + """); + } + catch + { + // XamlReader unavailable on this head — fall back to an unpainted root + // rather than failing the render. + _windowRoot = new Microsoft.UI.Xaml.Controls.Border(); + } + + _window.Content = _windowRoot; + return _windowRoot; + } + + // The {ThemeResource} binding above re-resolves itself on theme change, so + // nothing to re-apply here; kept as a seam for heads where the markup path + // fell back to a plain Border. + private void ApplyWindowRootBackground() { } + + /// True when no render is pending, in-flight, or queued. + public bool IsIdle => + _disposed || + (Volatile.Read(ref _renderPending) == 0 && !_isRendering && !_needsRerender); + + /// Awaits until the render loop is idle. Used by test harnesses. + public Task WaitForIdleAsync(int maxYields = 50) + { + if (_disposed) return Task.CompletedTask; + if (_renderPending == 0 && !_isRendering && !_needsRerender) + return Task.CompletedTask; + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int yields = 0; + void CheckIdle() + { + if (_disposed || (_renderPending == 0 && !_isRendering && !_needsRerender)) + { + tcs.TrySetResult(); + return; + } + if (++yields > maxYields) + { + tcs.TrySetResult(); + return; + } + if (!_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, CheckIdle)) + tcs.TrySetResult(); + } + if (!_dispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, CheckIdle)) + tcs.TrySetResult(); + return tcs.Task; + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _dispatcherQueue.TryEnqueue(() => + { + if (_themeListenerElement is not null) + _themeListenerElement.ActualThemeChanged -= OnActualThemeChanged; + _themeListenerElement = null; + }); + + _rootComponent?.Context.RunCleanups(); + _funcContext?.RunCleanups(); + + _reconciler.Dispose(); + _rootComponent = null; + _rootRenderFunc = null; + _funcContext = null; + _currentTree = null; + _currentControl = null; + + if (ReferenceEquals(ReactorApp.ActiveHostInternal, this)) + ReactorApp.ActiveHostInternal = null; + } + + private void ShowErrorFallback(Exception ex) + { + var errorPanel = Microsoft.UI.Reactor.Core.ErrorFallback.BuildPanel(ex); + if (ContentTarget is not null) + ContentTarget.Child = errorPanel; + else + EnsureWindowRoot().Child = errorPanel; + _currentControl = errorPanel; + _currentTree = null; + } +} diff --git a/src/Reactor.Uno/Hosting/UnoBootstrap.cs b/src/Reactor.Uno/Hosting/UnoBootstrap.cs new file mode 100644 index 000000000..d3329ea04 --- /dev/null +++ b/src/Reactor.Uno/Hosting/UnoBootstrap.cs @@ -0,0 +1,105 @@ +// Uno host-builder bootstrap + code-only Application subclass. +// +// Uno 6 unifies hosting behind UnoPlatformHostBuilder (Uno.UI.Hosting). The +// platform providers are compiled per-TFM: __WASM__ gets UseWebAssembly(); the +// desktop TFM gets the X11/Framebuffer/macOS/Win32 providers (the builder picks +// the one available at runtime). + +using System; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Reactor.Hosting; +using Uno.UI.Hosting; + +namespace Microsoft.UI.Reactor; + +internal static class UnoBootstrap +{ + public static void Run() => BuildHost().Run(); + + public static Task RunAsync() => BuildHost().RunAsync(); + + private static UnoPlatformHost BuildHost() + { + var builder = UnoPlatformHostBuilder.Create() + .App(() => new ReactorApplication()); + +#if __WASM__ + builder = builder.UseWebAssembly(); +#elif __IOS__ || __MACCATALYST__ || __TVOS__ + // Apple heads DO start from a real Main, and Uno gives them the same + // host-builder shape as desktop — so ReactorApp.Run works there + // unchanged, with UIKit as the platform provider. + builder = builder.UseAppleUIKit(); +#elif __ANDROID__ + // Android is the one target with no console entry point: the OS starts + // an Activity, so the head calls ReactorApp.CreateApplication() + // and this builder path is never reached. +#else + builder = builder + .UseX11() + .UseLinuxFrameBuffer() + .UseMacOS() + .UseWin32(); +#endif + + return builder.Build(); + } +} + +/// +/// The Uno that hosts a Reactor tree. Created by +/// ; reads startup config from . +/// +internal sealed class ReactorApplication : Application +{ + private ReactorWindow? _primary; + + protected override void OnLaunched(LaunchActivatedEventArgs args) + { + // Process-wide unhandled-exception routing. Mirrors the Windows host: + // log, then let an app-supplied hook decide whether to swallow. Unknown + // exceptions are left unhandled so the app crashes with a useful error + // rather than limping along corrupt. + UnhandledException += (_, e) => + { + ReactorApp.AppLogger?.LogError( + e.Exception, + "UnhandledException: {ExceptionType}: {ExceptionMessage}", + e.Exception.GetType().Name, e.Exception.Message); + if (ReactorApp.OnUnhandledException is not null) + e.Handled = ReactorApp.OnUnhandledException(e.Exception); + }; + + // Marshal cross-thread setState callbacks back onto the UI thread. + var dq = DispatcherQueue.GetForCurrentThread(); + if (dq is not null) + { + SynchronizationContext.SetSynchronizationContext( + new DispatcherQueueSynchronizationContext(dq)); + } + + // WinUI control templates/styles. + try { Resources.MergedDictionaries.Add(new XamlControlsResources()); } + catch { /* resources already present / headless */ } + + var opts = ReactorApp.Options; + + var spec = new WindowSpec + { + Title = opts.WindowTitle, + Width = opts.WindowWidth, + Height = opts.WindowHeight, + FullScreen = opts.FullScreen, + }; + + // The primary window goes through the same construction path as every + // secondary window opened later via ReactorApp.OpenWindow / UseOpenWindow. + _primary = ReactorApp.OpenWindowCore( + spec, opts.RootFactory, opts.RootRenderFunc, opts.Configure); + } +} diff --git a/src/Reactor.Uno/Hosting/Windowing.cs b/src/Reactor.Uno/Hosting/Windowing.cs new file mode 100644 index 000000000..6ddd16575 --- /dev/null +++ b/src/Reactor.Uno/Hosting/Windowing.cs @@ -0,0 +1,592 @@ +// Minimal windowing layer for the Uno port. +// +// The Windows framework's windowing stack (src/Reactor/Hosting/ReactorWindow.cs, +// ReactorDisplay.cs, WindowSpec.cs, …) is heavily Win32/AppWindow/DWM coupled and +// is excluded from the Uno build. The shared core (Core/RenderContext.cs windowing +// hooks, Core/Element.cs title-bar wiring) still references these *types*, so this +// file provides Uno-friendly equivalents with the exact member surface the shared +// source touches. Windows are real: ReactorApp.OpenWindow builds one of these per +// Microsoft.UI.Xaml.Window, each with its own ReactorHost. The chrome members Skia +// can't honour (tray, drag-move, aspect-ratio lock, display enumeration) are no-op +// stubs so the shared core still compiles and degrades gracefully. + +using System; +using System.Collections.Generic; +using System.Threading; +using Microsoft.UI.Reactor.Core; +using Microsoft.UI.Windowing; +using WinUIWindow = Microsoft.UI.Xaml.Window; + +namespace Microsoft.UI.Reactor; + +/// Window display state. Mirrors the Windows framework's enum. +public enum WindowState +{ + Normal, + Minimized, + Maximized, +} + +/// Stable identity key for UseOpenWindow de-duplication. +public readonly record struct WindowKey(string Name); + +/// +/// Caption height for a window whose content extends into the title bar. +/// Mirrors the Windows framework's enum (and Microsoft.UI.Windowing.TitleBarHeightOption). +/// +/// +/// Declared here rather than source-shared because it lives in the Windows-only +/// Hosting/WindowSpec.cs, which the Uno port replaces — but the shared +/// Core/Element.cs and Elements/ElementExtensions.cs reference the +/// type (TitleBarElement.HeightOption / .Tall(), issue #917). +/// Uno implements the underlying AppWindowTitleBar.PreferredHeightOption +/// on the desktop heads, so this is honoured for real. +/// +public enum WindowTitleBarHeight +{ + /// Standard 32 DIP caption. + Standard, + /// Tall 48 DIP caption — the layout used when the title bar hosts navigation chrome. + Tall, + /// No caption area at all. + Collapsed, +} + +/// +/// Declarative description of a window's chrome. Only the members the shared +/// core reads are modelled; the rest of the Windows WindowSpec surface +/// (backdrop, embed, persistence, splitters, …) is intentionally omitted. +/// +public sealed record WindowSpec +{ + public string Title { get; init; } = "Reactor App"; + + /// + /// Initial DIP width. null (the default) leaves the initial width to + /// the OS, matching the Windows framework (issue #924) — Reactor does not + /// override that axis. When both axes are null no resize is issued. + /// + public double? Width { get; init; } + + /// Initial DIP height. null defers to the OS. See . + public double? Height { get; init; } + + public bool FullScreen { get; init; } + public WindowKey? Key { get; init; } + /// Null = framework default; true/false = explicit opt-in/out. + public bool? ExtendsContentIntoTitleBar { get; init; } + + /// + /// System caption height. null leaves the platform default. Honoured + /// on the Uno desktop heads via AppWindow.TitleBar.PreferredHeightOption. + /// + public WindowTitleBarHeight? TitleBarHeight { get; init; } +} + +/// Tray-icon spec stub — tray icons are a Windows shell feature. +public sealed record TrayIconSpec(WindowKey Key); + +/// Live tray-icon handle stub. +public sealed class ReactorTrayIcon +{ + internal ReactorTrayIcon(TrayIconSpec spec) => Spec = spec; + public TrayIconSpec Spec { get; private set; } + public void Update(TrayIconSpec spec) => Spec = spec; + public void Close() { } +} + +/// Snapshot of a single display. Minimal shape used by UseDisplays. +public readonly record struct DisplayInfo( + string Name, + double X, + double Y, + double Width, + double Height, + double Scale, + bool IsPrimary); + +/// +/// Display enumeration. Skia heads don't expose a portable multi-monitor query, +/// so this returns an empty snapshot and never raises layout-change events. +/// +public static class ReactorDisplay +{ + public static IReadOnlyList Displays { get; } = Array.Empty(); + +#pragma warning disable CS0067 // event is part of the API surface; never raised on Skia + public static event EventHandler? DisplayLayoutChanged; +#pragma warning restore CS0067 +} + +/// Event args for . +public sealed class WindowDipPositionChangedEventArgs : EventArgs +{ + public WindowDipPositionChangedEventArgs((double X, double Y) position) => Position = position; + public (double X, double Y) Position { get; } +} + +/// Event args for . +public sealed class WindowZOrderChangedEventArgs : EventArgs +{ + public WindowZOrderChangedEventArgs(bool movedToTop, bool isCovered) + { + MovedToTop = movedToTop; + IsCovered = isCovered; + } + public bool MovedToTop { get; } + public bool IsCovered { get; } +} + +/// +/// Uno wrapper over a . Bridges the +/// shared windowing hooks to the live Skia window. Members the core never reads +/// are best-effort/no-op on Skia. +/// +public sealed class ReactorWindow +{ + private readonly object _titleBarLock = new(); + private bool _titleBarControlPresent; + + internal ReactorWindow(WinUIWindow nativeWindow, WindowSpec spec) + { + NativeWindow = nativeWindow; + Spec = spec; + + // Bridge the WinUI window's activation events to the simple EventHandler + // shape the shared hooks expect. + nativeWindow.Activated += (_, args) => + { + bool deactivated = + args.WindowActivationState == global::Windows.UI.Core.CoreWindowActivationState.Deactivated; + if (deactivated) + { + IsActive = false; + Deactivated?.Invoke(this, EventArgs.Empty); + } + else + { + IsActive = true; + Activated?.Invoke(this, EventArgs.Empty); + } + }; + } + + /// The underlying WinUI/Uno window. + public WinUIWindow NativeWindow { get; } + + /// The spec this window was opened with. + public WindowSpec Spec { get; private set; } + + /// The Reactor host rendering into this window. + public Microsoft.UI.Reactor.Hosting.ReactorHost? Host { get; internal set; } + + public bool IsActive { get; private set; } = true; + public WindowState State { get; private set; } = WindowState.Normal; + public (double X, double Y) Position { get; private set; } + + /// Per-window persisted-state scope (backs UsePersisted(PersistedScope.Window)). + public Microsoft.UI.Reactor.Core.IPersistedStateScope PersistedScope { get; } + = new Microsoft.UI.Reactor.Core.WindowPersistedScope(); + + /// + /// Per-window DPI as a raw dots-per-inch value (96 = 100%). Derived from the + /// window's XamlRoot.RasterizationScale when available. + /// + public uint Dpi + { + get + { + try + { + var scale = NativeWindow.Content?.XamlRoot?.RasterizationScale ?? 1.0; + return (uint)Math.Round(96.0 * (scale <= 0 ? 1.0 : scale)); + } + catch { return 96; } + } + } + + /// Raised when this window's DPI changes (monitor move or display-scale change). + public event EventHandler? DpiChanged; + + /// Raised when the window becomes active. + public event EventHandler? Activated; + + /// Raised when the window is deactivated. + public event EventHandler? Deactivated; + +#pragma warning disable CS0067 // Skia heads don't surface these; kept as API surface. + public event EventHandler? PositionChanged; + public event EventHandler? ZOrderChanged; + public event EventHandler? StateChanged; +#pragma warning restore CS0067 + + // ── DPI-change notification ── + // + // Uno raises XamlRoot.Changed when the window's RasterizationScale changes + // (dragged to a monitor with a different scale, or the display scale is + // changed). It also fires for size changes, so only surface DpiChanged when + // the DPI value actually moved. Each window has its own XamlRoot, so this is + // correctly per-window under multi-window. + private Microsoft.UI.Xaml.XamlRoot? _xamlRoot; + private uint _lastDpi; + + // Called by ReactorHost whenever it installs new content into this window — + // the XamlRoot only exists once content is attached. + internal void OnContentAttached(Microsoft.UI.Xaml.UIElement? content) + { + var root = content?.XamlRoot; + + // XamlRoot is not assigned when Content is merely *set* — it appears + // when the element enters the live visual tree, which happens after + // Window.Activate(). Reactor attaches content from the render loop, so + // the first call here always arrives with a null XamlRoot. Returning + // early on that (null == null compares equal) would permanently skip + // both the DPI listener and the initial resize, so re-arm on Loaded and + // come back once the root really exists. + if (root is null) + { + if (content is Microsoft.UI.Xaml.FrameworkElement fe) + { + void OnLoaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs args) + { + fe.Loaded -= OnLoaded; + OnContentAttached(fe); + } + fe.Loaded -= OnLoaded; + fe.Loaded += OnLoaded; + } + return; + } + + if (ReferenceEquals(root, _xamlRoot)) return; + + if (_xamlRoot is not null) + _xamlRoot.Changed -= OnXamlRootChanged; + + _xamlRoot = root; + + _lastDpi = Dpi; + _xamlRoot.Changed += OnXamlRootChanged; + + // The XamlRoot has just appeared, so RasterizationScale is finally real — + // retry the initial resize if it had to be deferred (see ApplyInitialSize). + ApplyInitialSize(); + } + + private bool _initialSizeApplied; + + /// + /// Applies / once. + /// + /// + /// Spec sizes are DIPs, but AppWindow.Resize takes + /// physical pixels — Uno's Win32 head hands the SizeInt32 + /// straight to SetWindowPos, and Uno's own startup path multiplies by + /// RasterizationScale for exactly this reason. Without the conversion + /// every window is undersized above 100% scale (a 480x360 request measured + /// 320x240 DIP at 150%). + /// The scale is only knowable once the window has a XamlRoot, + /// which does not exist at Activate time because Reactor attaches + /// content from the dispatcher-queued render loop. So this is attempted + /// after activation and, if the scale isn't available yet, re-attempted from + /// . It applies at most once either way. + /// A null axis means "let the OS pick" (issue #924); a spec with + /// neither axis set never resizes. + /// + internal void ApplyInitialSize() + { + if (_initialSizeApplied) return; + + if (Spec.Width is null && Spec.Height is null) + { + _initialSizeApplied = true; + return; + } + + var scale = NativeWindow.Content?.XamlRoot?.RasterizationScale ?? 0; + if (scale <= 0) return; // not realized yet — OnContentAttached retries + + try + { + var appWindow = NativeWindow.AppWindow; + if (appWindow is null) + { + _initialSizeApplied = true; + return; + } + + // Preserve the OS-chosen extent on whichever axis the spec leaves null. + var current = appWindow.Size; + int width = Spec.Width is { } w ? (int)Math.Round(w * scale) : current.Width; + int height = Spec.Height is { } h ? (int)Math.Round(h * scale) : current.Height; + + appWindow.Resize(new global::Windows.Graphics.SizeInt32 + { + Width = width, + Height = height, + }); + } + catch { /* sizing unsupported on this head */ } + + _initialSizeApplied = true; + } + + private void OnXamlRootChanged( + Microsoft.UI.Xaml.XamlRoot sender, + Microsoft.UI.Xaml.XamlRootChangedEventArgs args) + { + uint dpi = Dpi; + if (dpi == _lastDpi) return; + + _lastDpi = dpi; + DpiChanged?.Invoke(this, dpi); + } + + /// Lifetime-bound aspect-ratio lock. No-op on Skia; returns a disposable token. + public IDisposable RegisterAspectRatioOverride(double? widthOverHeight) => NoopDisposable.Instance; + + // ── closing guards ── + // + // Backed by Uno's AppWindow.Closing, which honours cancellation on the desktop + // heads (Windows / macOS / Linux). On Android, iOS and WebAssembly the event + // still fires but Cancel has no effect, so the close proceeds — matching Uno's + // documented behaviour rather than pretending to guard. + // + // AppWindow.Closing must be handled synchronously (async work does not delay the + // close), which is exactly the Func contract the shared UseClosingGuard + // hook exposes. + private readonly object _closingGuardsLock = new(); + private readonly List> _closingGuards = new(); + private bool _closingHooked; + + /// + /// Registers a synchronous "can the window close right now?" predicate. + /// Multiple guards stack — any returning false cancels the close. + /// Dispose the returned token to unregister. + /// + public IDisposable RegisterClosingGuard(Func canClose) + { + ArgumentNullException.ThrowIfNull(canClose); + + lock (_closingGuardsLock) + { + _closingGuards.Add(canClose); + EnsureClosingHooked(); + } + + return new GuardToken(this, canClose); + } + + // Subscribe lazily: a window that never registers a guard pays nothing, and by + // the time a guard arrives (from an effect, so post-mount) the AppWindow exists. + private void EnsureClosingHooked() + { + if (_closingHooked) return; + + try + { + var appWindow = NativeWindow.AppWindow; + if (appWindow is null) return; + + appWindow.Closing += OnAppWindowClosing; + _closingHooked = true; + } + catch { /* AppWindow unavailable on this head */ } + } + + private void OnAppWindowClosing( + Microsoft.UI.Windowing.AppWindow sender, + Microsoft.UI.Windowing.AppWindowClosingEventArgs args) + { + Func[] guards; + lock (_closingGuardsLock) { guards = _closingGuards.ToArray(); } + + for (int i = 0; i < guards.Length; i++) + { + bool canClose; + + // A guard is app code. A throwing guard fail-safes to "cancel" rather + // than escaping mid-close, matching the Windows framework's behaviour. + try { canClose = guards[i](); } + catch { canClose = false; } + + if (!canClose) + { + args.Cancel = true; + return; + } + } + } + + private sealed class GuardToken : IDisposable + { + private readonly ReactorWindow _owner; + private Func? _guard; + + public GuardToken(ReactorWindow owner, Func guard) + { + _owner = owner; + _guard = guard; + } + + public void Dispose() + { + var g = Interlocked.Exchange(ref _guard, null); + if (g is null) return; + lock (_owner._closingGuardsLock) { _owner._closingGuards.Remove(g); } + } + } + + /// Starts a framework-managed window drag/move loop. No-op on Skia. + public void BeginDragMove() { } + + /// Records that a WinUI TitleBar control is mounted in this window. + public void MarkTitleBarControlPresent() + { + lock (_titleBarLock) { _titleBarControlPresent = true; } + } + + internal bool TitleBarControlPresent + { + get { lock (_titleBarLock) { return _titleBarControlPresent; } } + } + + // ── Caption height (issue #917) ──────────────────────────────────────── + // + // The shared TitleBar element and reconciler call into these three members, + // so the Uno window has to provide them. They are NOT stubs: Uno implements + // AppWindowTitleBar.PreferredHeightOption for real on the desktop heads + // (Standard=32 / Tall=48 / Collapsed=0), so the caption half genuinely works. + // + // The *control* half (sizing the Microsoft.UI.Xaml.Controls.TitleBar so it + // agrees with the caption) is kept faithful to the Windows implementation + // even though Uno currently ships that control as a not-implemented stub — + // writing Height on it is harmless today and correct the moment Uno lands it. + + private WeakReference? _titleBarControl; + private bool _titleBarControlExplicitHeight; + private bool _titleBarControlHeightOwned; + private WindowTitleBarHeight? _elementTitleBarHeight; + private WindowTitleBarHeight? _effectiveTitleBarHeight; + + /// + /// Withdraws a departing TitleBar(...) element's caption-height + /// contribution so a window that merely used to host one is not left tall + /// forever. Mirrors the Windows framework, including deliberately NOT + /// clearing (that latch drives + /// close-time teardown safety, where "was mounted at some point" is right). + /// + internal void ClearTitleBarControl() + { + _titleBarControl = null; + _titleBarControlExplicitHeight = false; + _titleBarControlHeightOwned = false; + _elementTitleBarHeight = null; + ApplyTitleBarHeight(); + } + + /// + /// Records the caption height declared by the mounted TitleBar(...) + /// element and applies both halves. , + /// when set, wins over the element's declaration. + /// + internal void SetElementTitleBarHeight( + WindowTitleBarHeight? height, + Microsoft.UI.Xaml.FrameworkElement? control, + bool controlHasExplicitHeight) + { + _elementTitleBarHeight = height; + _titleBarControl = control is null + ? null + : new WeakReference(control); + _titleBarControlExplicitHeight = controlHasExplicitHeight; + ApplyTitleBarHeight(); + } + + /// + /// Sizes the mounted WinUI TitleBar control to the caption height + /// Reactor actually applied — the control does not track the caption, so + /// Reactor pairs the two. An explicit .Height(...) owns the control + /// outright, and Reactor only clears a height it set itself. + /// + internal void SyncTitleBarControlHeight() + { + if (_titleBarControlExplicitHeight) return; + if (_titleBarControl is null || !_titleBarControl.TryGetTarget(out var control)) return; + + try + { + if (_effectiveTitleBarHeight == WindowTitleBarHeight.Tall) + { + if (_titleBarControlHeightOwned + && Math.Abs(control.Height - TitleBarElement.TallTitleBarControlHeight) < 0.5) + { + return; + } + control.Height = TitleBarElement.TallTitleBarControlHeight; + _titleBarControlHeightOwned = true; + } + else if (_titleBarControlHeightOwned) + { + control.ClearValue(Microsoft.UI.Xaml.FrameworkElement.HeightProperty); + _titleBarControlHeightOwned = false; + } + } + catch { /* control not realized on this head */ } + } + + // Writes the native caption height. The spec wins over the element; a + // non-extended window can't host a resized caption, so the control is held + // at Standard in that case (same rule as the Windows framework). + private void ApplyTitleBarHeight() + { + var resolved = Spec.TitleBarHeight ?? _elementTitleBarHeight; + if (resolved is null) + { + _effectiveTitleBarHeight = null; + SyncTitleBarControlHeight(); + return; + } + + try + { + var titleBar = NativeWindow.AppWindow?.TitleBar; + if (titleBar is null || !titleBar.ExtendsContentIntoTitleBar) + { + _effectiveTitleBarHeight = WindowTitleBarHeight.Standard; + SyncTitleBarControlHeight(); + return; + } + + titleBar.PreferredHeightOption = resolved.Value switch + { + WindowTitleBarHeight.Tall => TitleBarHeightOption.Tall, + WindowTitleBarHeight.Collapsed => TitleBarHeightOption.Collapsed, + _ => TitleBarHeightOption.Standard, + }; + _effectiveTitleBarHeight = resolved; + } + catch { /* caption sizing unsupported on this head */ } + + SyncTitleBarControlHeight(); + } + + /// Applies a changed spec to the live window (title only on Skia). + public void Update(WindowSpec spec) + { + Spec = spec; + try { NativeWindow.Title = spec.Title; } catch { /* best effort */ } + } + + /// Closes the underlying window. + public void Close() + { + try { NativeWindow.Close(); } catch { /* best effort */ } + } + + private sealed class NoopDisposable : IDisposable + { + public static readonly NoopDisposable Instance = new(); + public void Dispose() { } + } +} diff --git a/src/Reactor.Uno/README.md b/src/Reactor.Uno/README.md new file mode 100644 index 000000000..0f91f507d --- /dev/null +++ b/src/Reactor.Uno/README.md @@ -0,0 +1,339 @@ +# Microsoft.UI.Reactor on Uno Platform (Skia) + +A port of [Microsoft.UI.Reactor](../../README.md) — the declarative, hooks-based, +component MVU framework for WinUI 3 — to **Uno Platform Skia** targets, so the +exact same Reactor C# can run on **desktop and WebAssembly** (and, theoretically, +mobile) instead of only Windows / Windows App SDK. + +Reactor renders *real `Microsoft.UI.Xaml` controls*. Uno Platform reimplements +that same `Microsoft.UI.Xaml` API surface across Skia heads. So this port is +fundamentally a **retargeting**: the Reactor reconciler, hooks, DSL, layout +engine, charting, data grid, etc. are shared *verbatim* from `../Reactor`, +recompiled against Uno's `Microsoft.UI.Xaml`, with a small Uno-specific hosting +layer replacing the Windows-only windowing/shell stack. + +## Status + +| Target | TFM | State | +|---|---|---| +| Desktop (Win32 / X11 / macOS / Framebuffer) | `net10.0-desktop` | ✅ Builds **and runs** — interactive | +| WebAssembly | `net10.0-browserwasm` | ✅ Builds **and runs** in the browser | +| Android | `net10.0-android` | ✅ Builds to a **signed APK**; on-device run not yet verified | +| iOS | `net10.0-ios` | ✅ **Compiles** (on Windows); device deploy needs a Mac and is not yet verified | + +Both samples are **single Uno projects targeting all four heads** — the component +source is identical everywhere. Only the entry point differs: desktop and iOS use +`ReactorApp.Run()` (Uno gives the Apple heads the same host-builder shape as +desktop), wasm uses `RunAsync` (the browser thread can't block), and Android — the +only target with no console entry point — starts from an `Activity` that calls the +new `ReactorApp.CreateApplication()`. + +Verified end-to-end: `UseState` → `Button` click → reconciler diff → patched +`TextBlock` on Skia desktop, and the same app rendering **and responding to +clicks** in the browser via WASM. + +## Quick start — a file-based, single-file Reactor app + +This mirrors the [xaml.dev "file-based WinUI apps with Microsoft.UI.Reactor"](https://xaml.dev/post/file-based-winui-apps-with-microsoft-ui-reactor) +post, but cross-platform. The whole app is one `.cs` file +([`samples/Uno/file-based/Counter.cs`](../../samples/Uno/file-based/Counter.cs)): + +```csharp +#:sdk Uno.Sdk@6.7.0-dev.93 +#:project ../../../src/Reactor.Uno/Reactor.Uno.csproj +#:property TargetFramework=net10.0-desktop +#:property OutputType=Exe +#:property UnoSingleProject=true +#:property UnoFeatures=SkiaRenderer +#:property PublishAot=false +#:property PackAsTool=false + +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using static Microsoft.UI.Reactor.Factories; + +ReactorApp.Run("Single-File Reactor on Uno", width: 520, height: 400); + +class CounterApp : Component +{ + public override Element Render() + { + var (name, setName) = UseState("Uno"); + var (count, setCount) = UseState(0); + + return VStack(12, + Heading($"Hello, {name}!"), + TextBox(name, setName, placeholderText: "Your name"), + Heading($"Count: {count}"), + HStack(8, + Button("-", () => setCount(count - 1)), + Button("+", () => setCount(count + 1)) + ) + ).Padding(24); + } +} +``` + +Run it: + +```bash +cd samples/Uno/file-based +dotnet run Counter.cs +``` + +> **Versus the WinUI version.** The WinUI file-based app uses +> `#:property TargetFramework=net10.0-windows10.0.22621.0` + +> `#:property WindowsAppSDKSelfContained=true` and `dotnet run App.cs -a x64`. +> Here we swap to `#:sdk Uno.Sdk`, `TargetFramework=net10.0-desktop`, and add the +> Reactor.Uno reference. The Reactor code itself is **identical**. + +### Run on WebAssembly instead + +A single file-based app targets one TFM at a time. For WASM, set +`#:property TargetFramework=net10.0-browserwasm` and use the async entry point +(`await ReactorApp.RunAsync(...)`) because the browser thread can't +block. See [`samples/Uno/ReactorUnoCounter`](../../samples/Uno/ReactorUnoCounter) for a normal +multi-targeted project that does both from one source via `#if __WASM__`: + +```bash +cd samples/Uno/ReactorUnoCounter +dotnet run -f net10.0-desktop # desktop window +dotnet run -f net10.0-browserwasm # prints a localhost URL — open it +``` + +## Architecture + +The port lives under the repo's conventional `src/` and `samples/` folders, but +**each Uno location carries its own build-isolation files** so it never inherits +the Windows / Windows App SDK build infrastructure — the repo-root +`Directory.Build.props` imports `Reactor.targets` and pins WinAppSDK, and the +root `Directory.Packages.props` turns Central Package Management on. MSBuild +stops walking up at the nearest match, so these shadow files pin the `Uno.Sdk` +and turn CPM off for the Uno projects only; the Windows framework build is +untouched (and the Uno projects stay out of the main `Reactor.slnx`, so CI never +restores them). + +``` +src/Reactor.Uno/ # the port library (assembly `Reactor`, ns Microsoft.UI.Reactor) + Reactor.Uno.csproj # shares ../Reactor source, targets Uno TFMs + GlobalUsings.cs # disambiguation aliases (see below) + Hosting/ + ReactorApp.cs # ReactorApp.Run / RunAsync + the static surface the core needs + UnoBootstrap.cs # UnoPlatformHostBuilder wiring + the code-only Application + ReactorHost.cs # the render loop (reconcile → Window.Content → effects) + Windowing.cs # minimal ReactorWindow + windowing types for the shared hooks + global.json # pins Uno.Sdk 6.7.0-dev.93 + nuget.config # nuget.org (self-contained restore) + Directory.Build.props # stops inheritance of the repo-root Windows build infra + Directory.Packages.props # CPM off for this project + +samples/Uno/ # Uno samples — one shared isolation set for all three + global.json · nuget.config · Directory.Build.props · Directory.Packages.props + ReactorUnoCounter/ # normal Uno single-project app (desktop + wasm) + ReactorUnoShowcase/ # larger showcase app + file-based/Counter.cs # the single-file app + +Reactor.Uno.slnx # dedicated solution (kept out of the main Reactor.slnx) +``` + +### Source sharing + +`Reactor.Uno.csproj` pulls the portable framework folders in via explicit +`` globs, from **two** projects: + +- from `../Reactor` (core): `Core`, `Hooks`, `Elements`, `Input`, + `Accessibility`, `Animation`, `Yoga`, `Data`, `Controls`, `Diagnostics`, plus a + few portable `Hosting/*` files (the charting bridge, render stats, hot-reload + service, XAML interop); +- from `../Reactor.Advanced`: `Charting`, `Markdown` and `Controls/DataGrid`. + Spec 062 Track B moved these out of core into a separate Windows-only + *project*, but the *source* is portable — verified free of Win2D and Docking + coupling. `Docking/` and `Win2D/` stay excluded. + +The wrapper source-generator (`Reactor.Wrappers.Generator`) runs as an analyzer +to emit the control descriptors, exactly as in the Windows build. + +> **Source-share drift is the main hazard of this design.** A `` +> glob that matches zero files is completely silent in MSBuild, so when Track B +> moved those three subsystems the port lost 74 files with a green build. The +> `VerifySharedSourceRoots` target in `Reactor.Uno.csproj` now fails the build if +> any shared root stops existing; `ci-uno.yml` is path-gated on both +> `src/Reactor/**` and `src/Reactor.Advanced/**`. + +The assembly is named **`Reactor`** with root namespace +**`Microsoft.UI.Reactor`** — identical to the Windows package — so consumer +`using Microsoft.UI.Reactor;` and `ReactorApp.Run` work unchanged. + +### What's replaced or excluded + +The Windows `Hosting/` stack is Win32/DWM/AppWindow/shell P/Invoke-heavy, so it +is **not** shared; this project provides a slim Uno hosting layer instead: + +- **`ReactorApp`** — `Run` (sync, desktop) / `RunAsync` (async, WASM) build an + `UnoPlatformHostBuilder` and a code-only `Application`. Platform providers are + selected per-TFM (`UseX11`/`UseWin32`/`UseMacOS`/`UseLinuxFrameBuffer` on + desktop, `UseWebAssembly` on WASM). +- **`ReactorHost`** — a trimmed render loop (keeps coalescing, reconcile, effects + flush, perf stats, and the charting-activation seam; drops the system-backdrop, + dev-overlay, focus-revalidation, and in-render accessibility machinery). +- **`ReactorWindow` + windowing types** — minimal Uno-backed equivalents so the + shared windowing hooks (`UseWindow`, `UseWindowSize`, `UseDpi`, …) compile and + degrade gracefully. + +**Excluded subsystems:** `Docking/` (native tab tear-off / floating windows via +Win32 — the dock event-fluent region in `ElementExtensions.Events.cs` is guarded +with `#if !REACTOR_UNO`), the Windows shell integration (tray/jumplist/taskbar), +window persistence, and the in-app devtools menu. + +### Surgical shared-source edits + +The port touches only **two** shared files, and just one of them carries a +`REACTOR_UNO` conditional (the symbol is defined only by this project, so the +Windows build is **unaffected** — verified green): + +- `Elements/ElementExtensions.Events.cs` — the Docking event fluents (`#if !REACTOR_UNO`), + since `Docking/` is excluded from the port. +- `Core/Reconciler.Mount.cs` — `return null` → `return default` in a generic + helper. Not a conditional: semantically identical on both builds (`T` is + constrained to a reference type); it just satisfies Uno's nullable analysis. + +> A second conditional used to guard `Hyperlink.UnderlineStyle` (Uno declared the +> `UnderlineStyleProperty` DP identifier `internal`). That was fixed upstream in +> [unoplatform/uno#23652](https://github.com/unoplatform/uno/issues/23652), so as of +> `Uno.Sdk 6.7.0-dev.117` the guard is gone and the shared DP-based updater compiles +> unchanged. + +### Name disambiguation (`GlobalUsings.cs`) + +Uno's implicit `Microsoft.UI.Xaml.Controls` global using collides with Reactor's +own `Controls`/`Core` types and with `Microsoft.UI.Xaml`. The shared source +already carries its own `using` directives, so global aliases pin the few bare +names that would otherwise be ambiguous (`SelectionMode` → Reactor's; +`ElementFactoryGetArgs`/`RecycleArgs` → `Microsoft.UI.Xaml`). + +## Feature support on Uno Skia + +Legend: ✅ works · 🟡 partial / unverified · ❌ not supported (compiles, but no-ops or throws at runtime). + +| Area | Status | Notes | +| --- | :---: | --- | +| MVU core: hooks, state, effects, memo, reconciler | ✅ | Full parity with the Windows framework. | +| Layout: Grid, Stack, panels, Yoga/Flex | ✅ | | +| Core controls: Button, TextBlock, TextBox, ToggleSwitch, Slider, ProgressBar, CheckBox, ComboBox, ScrollView | ✅ | Sample-verified (desktop + wasm). | +| Theming (Light/Dark) + live theme-change re-render | ✅ | | +| Implicit animations & transitions (Scale/Rotation/Opacity/Translation transitions, spring / natural-motion, `ImplicitAnimationCollection`) | ❌ | Not implemented in Uno — the calls no-op. | +| RichTextBlock / rich inline text / Markdown rendering | 🟡 | Most `RichTextBlock` members are not implemented in Uno; rich text and Markdown render incompletely. | +| RichEditBox | ❌ | Not implemented in Uno. | +| Custom TitleBar (drag regions, back button, panes) | ❌ | Not implemented in Uno. | +| Specialty controls: ParallaxView, MapControl, SemanticZoom, LinedFlowLayout, AnnotatedScrollBar | ❌ | Not implemented in Uno. | +| Gesture/access flags: `IsTapEnabled` / `IsHoldingEnabled` / `IsDoubleTapEnabled` / `IsRightTapEnabled`, `AccessKey`, `CharacterReceived` | 🟡 | Those specific flags no-op; basic pointer/click still works. | +| High-contrast / forced-colors detection | ❌ | `AccessibilitySettings.HighContrast` not implemented — charts don't adapt to high contrast. | +| Single window + render loop + error fallback | ✅ | | +| Multi-window (`OpenWindow` / `UseOpenWindow`) | ✅ desktop | Real secondary windows on every desktop head (X11 / Win32 / macOS / FrameBuffer), each with its own `ReactorHost`, render loop and state. Android/iOS throw `InvalidOperationException` (Uno doesn't support secondary windows there) — `UseOpenWindow` catches it and degrades to a null handle. No OS windows in the browser (wasm). Demoed in `samples/Uno/ReactorUnoShowcase`. | +| DPI (`UseDpi`) | ✅ | Per-window DPI from `XamlRoot.RasterizationScale`, and live changes (window dragged to a monitor with a different scale, or the display scale changed) re-render via `XamlRoot.Changed`. Correctly per-window, since each window has its own `XamlRoot`. | +| File / folder pickers (`UseFilePickerAsync` / `UseFolderPickerAsync`) | ✅ desktop | Uno implements the WinRT pickers. The shared hook's `WindowNative.GetWindowHandle` + `InitializeWithWindow` association is exactly the initialization Uno's own docs prescribe for Uno.WinUI apps — it is not a Windows-only path. On **wasm** it depends on the browser: Uno uses the File System Access API where available (Chromium) and falls back to download/upload pickers; `FolderPicker` needs the File System Access API. Demoed in `samples/Uno/ReactorUnoShowcase`. | +| Tray icons / shell (jump list, taskbar) | ❌ | Stub no-ops. | +| Window persistence (placement save/restore) | ❌ | Not shared. | +| System backdrop / Mica / DWM effects | ❌ | Not shared. | +| Multi-monitor / display enumeration | ❌ | `ReactorDisplay.Displays` returns empty. | +| Window closing guards (`UseClosingGuard`) | ✅ desktop | Backed by Uno's `AppWindow.Closing`. Guards stack; any returning `false` cancels the close, and a throwing guard fail-safes to "cancel" (same as the Windows framework). Honoured on desktop Windows / macOS / Linux. On Android, iOS and wasm the event still fires but cancellation has no effect (per Uno), so the close proceeds. Demoed on the Showcase's second window. | +| Hot Reload (edit `Render()` while running) | ✅ | Reactor registers `[assembly: MetadataUpdateHandler]`, and Uno's own `HotReloadAgent` discovers and invokes **every** registered handler — so Reactor's re-render is driven by Uno's hot-reload pipeline on the targets `dotnet watch` alone can't reach. Needs no opt-in. `UseState` survives; hook add/remove/reorder recovers by remounting. See [Hot Reload](#hot-reload) below. | +| Declarative caption height (`TitleBar(...).Tall()`, `WindowSpec.TitleBarHeight`) | ✅ desktop | Backed by Uno's `AppWindowTitleBar.PreferredHeightOption` (Standard 32 / Tall 48 / Collapsed 0), which Uno implements for real. The WinUI `TitleBar` *control* is still an Uno stub, so only the caption half is visible today. | +| Window drag-move, aspect-ratio lock | ❌ | Still no-op stubs — **not yet audited** against Uno's API surface (multi-window, DPI, pickers and closing guards all turned out to be implementable, so these may be too). | +| Docking (dock manager, tab tear-off, floating windows, splitters) | ❌ | Excluded from the port entirely. | +| In-app devtools | ❌ | `DevtoolsEnabled` is `false`. | +| Charting / DataGrid / PropertyGrid | 🟡 | Source-shared from `Reactor.Advanced` and compiling on all three TFMs; render path shared with WinUI, but not yet runtime-verified on Skia. | +| Markdown rendering | 🟡 | Source-shared from `Reactor.Advanced`. Compiles, but depends on `RichTextBlock`, which Uno implements only partially — so rich output renders incompletely. | + +> The ❌ / 🟡 runtime rows line up with the `Uno0001` *not-implemented* build warnings: the code compiles, but those specific APIs no-op (or throw) on Uno. They don't affect the ✅ rows. + +## Hot Reload + +**Yes — editing a `Component.Render()` body updates the running app, and +`UseState` survives.** Verified on Skia desktop: + +``` +dotnet watch ⌚ File updated: .\Program.cs +dotnet watch 🔥 C# and Razor changes applied in 424ms. +``` +…with the counter still reading the value it held before the edit. + +### How it composes with Uno's Hot Reload + +Reactor does **not** implement a competing mechanism. `Hosting/HotReloadService.cs` +(source-shared from the Windows framework) registers the standard + +```csharp +[assembly: MetadataUpdateHandler(typeof(HotReloadService))] +``` + +and Uno's own `ClientHotReloadProcessor` registers itself the same way. They are +peers on one runtime mechanism, not layers. Crucially, Uno's `HotReloadAgent` +scans **every** loaded assembly for `MetadataUpdateHandlerAttribute` and invokes +what it finds: + +```csharp +// Uno.UI.RemoteControl/HotReload/MetadataUpdater/HotReloadAgent.cs +handlerActions.ClearCache.ForEach(a => a(updatedTypes)); +handlerActions.UpdateApplication.ForEach(a => a(updatedTypes)); +``` + +So on the targets where the Uno **Dev Server** delivers the deltas rather than +`dotnet watch` — WebAssembly, Android, iOS — Uno drives Reactor's re-render for +free, with no Reactor-side work at all. + +Nothing has to be opted into for this. There is **no `HotReload` UnoFeature** +(passing one just warns `Unable to parse 'hotreload' to a known Uno Feature` and +is ignored); the dev-server client `Uno.WinUI.DevServer` / `Uno.UI.RemoteControl` +is referenced automatically for Debug builds, and the Dev Server process itself is +started by the IDE. `.UseStudio()` is likewise not needed — it lives in +`Uno.UI.HotDesign` and turns on **Hot Design**, Uno's runtime *XAML* visual +designer, which does not apply to a XAML-free framework like Reactor. + +On an update Reactor re-renders the whole tree with `force: true` (bypassing memo), +migrates hook cells whose types were edited, and treats a `HookOrderException` as +"the edit changed the hook shape" — it drops that context's hook state and +remounts instead of showing the error overlay. + +### Caveat: multi-targeted heads under `dotnet watch` + +`dotnet watch -f ` against an app head whose csproj lists **more than one** +TFM currently loads a Roslyn workspace with no references, and every edit fails +with a wall of `CS0518: Predefined type 'System.Object' is not defined`. This is +not Reactor-specific — it reproduces from the TFM list alone: + +| Head `TargetFrameworks` | Result | +| --- | --- | +| `net10.0-desktop;net10.0-browserwasm` | ❌ `CS0518` cascade, hot reload dead | +| `net10.0-desktop` | ✅ `🔥 C# changes applied` | + +The referenced library may stay multi-targeted — only the **head** matters. So +when hot-reloading from the CLI, either single-target the head or use an IDE +(VS / VS Code / Rider), which drives the Uno Dev Server and picks one TFM per +debug session. The unrelated `Found project reference without a matching +metadata reference` warning is benign — it is present in the working case too. + +## Known limitations / notes + +- **Uno version.** The port requires Uno **6.7** APIs (`DispatcherQueueSynchronizationContext`, + the `TitleBar` drag-region APIs) that are absent from the current 6.5 GA, so it + pins the 6.7 preview — `Uno.Sdk 6.7.0-dev.117`, set in `global.json` (repo root, + `src/Reactor.Uno/`, `samples/Uno/`, and the file-based `Counter.cs` header). + Switch to 6.7 GA when it ships. The Skia runtime packages take their version from + `$(UnoVersion)`, which **Uno.Sdk supplies**, so they track the SDK automatically — + don't hardcode a version for them (it drifts and trips `NU1605`). +- **Don't name a file-based app's root component `App`** — Uno.Sdk generates an + `App` type for single-project EXEs, which would clash. Use any other name + (`CounterApp`, `MyApp`, …); `ReactorApp.Run` doesn't care. +- See the **Feature support on Uno Skia** matrix above for what works, what's + partial, and what's unsupported. The unsupported/partial rows surface as + `Uno0001` *not-implemented* build warnings — runtime no-ops in Uno, not build + breaks — and only affect those specific features. + +## Mobile + +`Reactor.Uno` already **compiles for `net10.0-android`**. To actually deploy to +Android/iOS you'd add a thin mobile *head* project (Uno single-project EXE) that +references `Reactor.Uno`, wires the native entry point (Activity / AppDelegate), +and mounts a root `Component` via a `ReactorHost`. The framework code itself is +platform-agnostic `Microsoft.UI.Xaml`, so no Reactor changes are expected. diff --git a/src/Reactor.Uno/Reactor.Uno.csproj b/src/Reactor.Uno/Reactor.Uno.csproj new file mode 100644 index 000000000..0b30a1533 --- /dev/null +++ b/src/Reactor.Uno/Reactor.Uno.csproj @@ -0,0 +1,225 @@ + + + + net10.0-desktop;net10.0-browserwasm;net10.0-android;net10.0-ios + + true + Library + true + + + Reactor + Microsoft.UI.Reactor + + enable + enable + true + + + $(DefineConstants);REACTOR_UNO + + + + false + false + + + SkiaRenderer; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_AllHostingFiles Include="..\Reactor\Hosting\*.cs" /> + <_TriagedHostingFiles Include="@(SharedHostingFile);@(ExcludedHostingFile)" /> + <_UntriagedHostingFiles Include="@(_AllHostingFiles)" Exclude="@(_TriagedHostingFiles)" /> + + + + + diff --git a/src/Reactor.Uno/WinRTShims.cs b/src/Reactor.Uno/WinRTShims.cs new file mode 100644 index 000000000..b4d6102ed --- /dev/null +++ b/src/Reactor.Uno/WinRTShims.cs @@ -0,0 +1,35 @@ +// Shims for CsWinRT types the shared source references but Uno does not have. +// +// On Windows, Reactor is compiled against the C#/WinRT projection, so WinUI types +// like Microsoft.UI.Xaml.Controls.Page carry [WinRT.WindowsRuntimeTypeAttribute] +// and live in the native WinRT type system. Uno has no projection at all — every +// type, WinUI's included, is an ordinary managed type. +// +// Declaring the attribute here rather than guarding the shared source with +// #if REACTOR_UNO keeps the port at *zero* conditionals in src/Reactor, and the +// behaviour is right by construction: nothing in an Uno app is WinRT-projected, +// so `IsDefined(typeof(WindowsRuntimeTypeAttribute), ...)` is correctly always +// false and FrameNavigation falls through to asking the XAML type resolver — +// which is the whole point of that check. + +namespace WinRT; + +/// +/// Uno-side stand-in for CsWinRT's marker attribute. Deliberately never applied +/// to anything: on Uno there are no WinRT-projected types. +/// +[global::System.AttributeUsage( + global::System.AttributeTargets.Class + | global::System.AttributeTargets.Interface + | global::System.AttributeTargets.Struct + | global::System.AttributeTargets.Enum + | global::System.AttributeTargets.Delegate, + Inherited = false)] +internal sealed class WindowsRuntimeTypeAttribute : global::System.Attribute +{ + public WindowsRuntimeTypeAttribute() { } + + public WindowsRuntimeTypeAttribute(string sourceMetadata) => SourceMetadata = sourceMetadata; + + public string? SourceMetadata { get; } +} diff --git a/src/Reactor.Uno/global.json b/src/Reactor.Uno/global.json new file mode 100644 index 000000000..4e96f889a --- /dev/null +++ b/src/Reactor.Uno/global.json @@ -0,0 +1,10 @@ +{ + "msbuild-sdks": { + "Uno.Sdk": "6.7.0-dev.117" + }, + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/src/Reactor.Uno/nuget.config b/src/Reactor.Uno/nuget.config new file mode 100644 index 000000000..e21d5c691 --- /dev/null +++ b/src/Reactor.Uno/nuget.config @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/src/Reactor.Uno/reactor-uno-explainer.html b/src/Reactor.Uno/reactor-uno-explainer.html new file mode 100644 index 000000000..0593f0102 --- /dev/null +++ b/src/Reactor.Uno/reactor-uno-explainer.html @@ -0,0 +1,301 @@ + + + + + +Microsoft.UI.Reactor → Uno Platform (Skia) + + + +
+

Microsoft.UI.Reactor → Uno Platform (Skia)

+

The same Reactor C# — reconciler, hooks, DSL — recompiled against Uno's Microsoft.UI.Xaml. Pick a target to see what changes (spoiler: only the directives & host).

+
+ +
+ + +
+
+
+ — + +
+
+

+    
+ +

Run it

+
+ +

How the port is wired

+
+
+

① Shared source — src/Reactor/** (compiled verbatim, 0 edits except 3 #if guards)

+
+ Core / reconcilerHooksElements (DSL) + YogaFlexAnimationInput + AccessibilityData / DataGridControls + MarkdownCharting (D3)Diagnostics +
+
+
▼ recompiled against ▼
+
+

② Uno's Microsoft.UI.Xaml (Skia renderer)

+
Same API surface Reactor already targets — Button, TextBox, NavigationView, … now drawn by Skia on every head.
+
+
▼ hosted by ▼
+
+

③ Slim Uno hosting layer (replaces Windows windowing/shell/DWM P/Invoke)

+
+ ReactorApp.Run / RunAsyncUnoBootstrap → UnoPlatformHostBuilder + ReactorHost (render loop)Windowing (ReactorWindow) +
+
+
+

Excluded on Skia

+
+ Docking (native tear-off)Shell (tray/jumplist/taskbar) + Window persistenceDevtools menu +
+
+
+
+
+ +
+ + copies the #: directives + the component as one runnable .cs file +
+ + + + From 2f8c5f1f70a8b2c557aac648bf93604b6ef80b46 Mon Sep 17 00:00:00 2001 From: Martin Zikmund Date: Thu, 6 Aug 2026 13:47:33 +0200 Subject: [PATCH 3/5] feat(uno): add Uno samples and Reactor.Uno.slnx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three samples plus a dedicated solution. ReactorUnoCounter and ReactorUnoShowcase are Uno single-projects targeting all four heads — desktop, WebAssembly, Android, iOS — from one project. The Component source is identical everywhere; only the entry point differs: desktop and iOS use ReactorApp.Run() (Uno gives the Apple heads the same host-builder shape as desktop), wasm uses RunAsync because the browser thread cannot block, and Android — the one target with no console entry point — starts from an Activity that calls ReactorApp.CreateApplication(). file-based/Counter.cs is the whole app in a single .cs file via `#:sdk Uno.Sdk`, run with `dotnet run Counter.cs`. Two things worth knowing, both documented in samples/Uno/README.md: - Reactor apps contain no XAML, so there is no ApplicationDefinition for Uno's iOS Hot Restart helper generator to read and it fails the build with Uno0005. The samples set UnoDisableHotRestartHelperGeneration, which disables only VS's iOS *Hot Restart* deploy helper, not Hot Reload. - A Debug Android build uses Fast Deployment, so the managed assemblies are not in the APK. `adb install` of the APK produces an app that aborts at startup; deploy with `-t:Run` instead. Reactor.Uno.slnx is kept out of the main Reactor.slnx on purpose so the Windows CI never restores Uno.Sdk, and each Uno location carries its own global.json / nuget.config / Directory.Build.props so it does not inherit the repo-root Windows + WinAppSDK build infrastructure. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017rs9N4ZH4ss5tDtBwBN1Go --- Reactor.Uno.slnx | 27 ++++ samples/Uno/Directory.Build.props | 16 +++ samples/Uno/Directory.Packages.props | 11 ++ samples/Uno/README.md | 127 ++++++++++++++++++ .../Platforms/Android/AndroidManifest.xml | 4 + .../Platforms/Android/Main.Android.cs | 22 +++ .../Platforms/Android/MainActivity.Android.cs | 13 ++ .../Android/Resources/values/Strings.xml | 4 + .../Android/Resources/values/Styles.xml | 11 ++ .../Platforms/Android/environment.conf | 2 + .../Platforms/iOS/Entitlements.plist | 6 + .../Platforms/iOS/Info.plist | 43 ++++++ .../LaunchImages.launchimage/Contents.json | 58 ++++++++ .../Platforms/iOS/PrivacyInfo.xcprivacy | 41 ++++++ samples/Uno/ReactorUnoCounter/Program.cs | 36 +++++ .../ReactorUnoCounter.csproj | 33 +++++ .../Platforms/Android/AndroidManifest.xml | 4 + .../Platforms/Android/Main.Android.cs | 22 +++ .../Platforms/Android/MainActivity.Android.cs | 13 ++ .../Android/Resources/values/Strings.xml | 4 + .../Android/Resources/values/Styles.xml | 11 ++ .../Platforms/Android/environment.conf | 2 + .../Platforms/iOS/Entitlements.plist | 6 + .../Platforms/iOS/Info.plist | 43 ++++++ .../LaunchImages.launchimage/Contents.json | 58 ++++++++ .../Platforms/iOS/PrivacyInfo.xcprivacy | 41 ++++++ samples/Uno/ReactorUnoShowcase/Program.cs | 117 ++++++++++++++++ .../ReactorUnoShowcase.csproj | 31 +++++ samples/Uno/file-based/Counter.cs | 35 +++++ samples/Uno/global.json | 10 ++ samples/Uno/nuget.config | 13 ++ 31 files changed, 864 insertions(+) create mode 100644 Reactor.Uno.slnx create mode 100644 samples/Uno/Directory.Build.props create mode 100644 samples/Uno/Directory.Packages.props create mode 100644 samples/Uno/README.md create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/Android/AndroidManifest.xml create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/Android/Main.Android.cs create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/Android/MainActivity.Android.cs create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/Android/Resources/values/Strings.xml create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/Android/Resources/values/Styles.xml create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/Android/environment.conf create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/iOS/Entitlements.plist create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/iOS/Info.plist create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json create mode 100644 samples/Uno/ReactorUnoCounter/Platforms/iOS/PrivacyInfo.xcprivacy create mode 100644 samples/Uno/ReactorUnoCounter/Program.cs create mode 100644 samples/Uno/ReactorUnoCounter/ReactorUnoCounter.csproj create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/Android/AndroidManifest.xml create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/Android/Main.Android.cs create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/Android/MainActivity.Android.cs create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/Android/Resources/values/Strings.xml create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/Android/Resources/values/Styles.xml create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/Android/environment.conf create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/iOS/Entitlements.plist create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/iOS/Info.plist create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json create mode 100644 samples/Uno/ReactorUnoShowcase/Platforms/iOS/PrivacyInfo.xcprivacy create mode 100644 samples/Uno/ReactorUnoShowcase/Program.cs create mode 100644 samples/Uno/ReactorUnoShowcase/ReactorUnoShowcase.csproj create mode 100644 samples/Uno/file-based/Counter.cs create mode 100644 samples/Uno/global.json create mode 100644 samples/Uno/nuget.config diff --git a/Reactor.Uno.slnx b/Reactor.Uno.slnx new file mode 100644 index 000000000..35c9fdbd8 --- /dev/null +++ b/Reactor.Uno.slnx @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/samples/Uno/Directory.Build.props b/samples/Uno/Directory.Build.props new file mode 100644 index 000000000..cd4e8b70c --- /dev/null +++ b/samples/Uno/Directory.Build.props @@ -0,0 +1,16 @@ + + + + latest + + diff --git a/samples/Uno/Directory.Packages.props b/samples/Uno/Directory.Packages.props new file mode 100644 index 000000000..22dae8cb0 --- /dev/null +++ b/samples/Uno/Directory.Packages.props @@ -0,0 +1,11 @@ + + + + false + + diff --git a/samples/Uno/README.md b/samples/Uno/README.md new file mode 100644 index 000000000..c3b2651c9 --- /dev/null +++ b/samples/Uno/README.md @@ -0,0 +1,127 @@ +# Reactor on Uno Platform — samples + +Samples that run **Microsoft.UI.Reactor** on Uno Platform Skia targets +(desktop, WebAssembly, Android), backed by the +[`Reactor.Uno`](../../src/Reactor.Uno) port library. + +| Sample | What it shows | Targets | +| --- | --- | --- | +| [`ReactorUnoCounter`](ReactorUnoCounter) | Minimal counter — the smallest Reactor-on-Uno app | desktop, wasm, android, ios | +| [`ReactorUnoShowcase`](ReactorUnoShowcase) | ToggleSwitch, Slider, ProgressBar, CheckBox, ComboBox, pickers, multi-window | desktop, wasm, android, ios | +| [`file-based/Counter.cs`](file-based/Counter.cs) | A whole WinUI-style Reactor app in **one `.cs` file** (`dotnet run Counter.cs`) | desktop | + +Both app samples are **single Uno projects targeting all four heads** — the +`Component` source is identical everywhere; only the entry point differs. + +## Prerequisites + +- **.NET 10 SDK** (`10.0.100`+ — see [`global.json`](global.json)) +- The **`wasm-tools`** workload for the WebAssembly target: `dotnet workload install wasm-tools` +- The **`android`** / **`ios`** workloads for the mobile heads: `dotnet workload install android ios` + (iOS compiles on Windows; deploying to a device still needs a Mac) + +The Uno projects resolve `Uno.Sdk` from the nearby `global.json` and restore from +`nuget.org` only (see [`nuget.config`](nuget.config)); they do **not** inherit the +repo-root Windows/WinAppSDK build infrastructure. + +## Run + +### ReactorUnoCounter / ReactorUnoShowcase + +```bash +# Desktop (X11 / Win32 / macOS / FrameBuffer — picked at runtime) +cd samples/Uno/ReactorUnoCounter +dotnet run -f net10.0-desktop + +# WebAssembly (opens a localhost URL in your browser) +dotnet run -f net10.0-browserwasm +``` + +Swap `ReactorUnoCounter` for `ReactorUnoShowcase` to run the control showcase. + +### Android / iOS + +The same projects, different heads. Only the entry point is platform-specific: +desktop and **iOS** both use `ReactorApp.Run()` (Uno gives the Apple heads the +same host-builder shape as desktop); **Android** is the one target with no console +entry point, so `Platforms/Android/Main.Android.cs` starts from an `Activity` and +asks Reactor for the `Application`: + +```csharp +public class Application : Microsoft.UI.Xaml.NativeApplication +{ + public Application(IntPtr javaReference, JniHandleOwnership transfer) + : base(() => ReactorApp.CreateApplication("Reactor Counter (Uno)"), + javaReference, transfer) { } +} +``` + +```bash +cd samples/Uno/ReactorUnoCounter + +# Deploy + launch on a connected device or emulator (check `adb devices` first) +dotnet build -f net10.0-android -t:Run + +# iOS — compiles on Windows; deploying to a device needs a Mac +dotnet build -f net10.0-ios +``` + +> **Don't sideload the Debug APK.** A Debug Android build uses .NET's *Fast +> Deployment*: the managed assemblies are **not inside the APK** — the deploy +> tooling pushes them separately to `files/.__override__/`. Installing the APK by +> hand therefore produces an app that aborts at startup with +> `No assemblies found in '…/.__override__/arm64-v8a'` (SIGABRT), which the phone +> reports only as a generic failure. Use `-t:Run`, or build a self-contained APK +> with `-p:EmbedAssembliesIntoApk=true` (or `-c Release`) if you really need to +> sideload one. + +> Reactor apps contain **no XAML**, so there is no `ApplicationDefinition` for +> Uno's iOS Hot Restart helper generator to read and it fails with `Uno0005`. The +> samples set `true`, which disables +> only VS's iOS *Hot Restart* deploy helper — not Hot Reload. + +### file-based/Counter.cs + +A single-file app — no project, no `.csproj`. Reactor owns the Uno `Application`, +so the file's root component must **not** be named `App` (Uno.Sdk generates its own +`App`). + +```bash +cd samples/Uno/file-based +dotnet run Counter.cs +``` + +## Build the whole Uno set + +From the repo root: + +```bash +dotnet build Reactor.Uno.slnx +``` + +The solution builds per-TFM across all four heads: + +```bash +dotnet build Reactor.Uno.slnx -f net10.0-desktop +dotnet build Reactor.Uno.slnx -f net10.0-browserwasm +dotnet build Reactor.Uno.slnx -f net10.0-android +dotnet build Reactor.Uno.slnx -f net10.0-ios +``` + +> The file-based `Counter.cs` is not a project and is not in the solution — run it +> directly with `dotnet run Counter.cs`. + +## Hot Reload + +`dotnet watch` (desktop) and the Uno Dev Server (wasm / Android / iOS) both +re-render edits to a `Component.Render()` body live, preserving `UseState`. + +No opt-in is required: there is **no `HotReload` UnoFeature**, the dev-server +client is referenced automatically in Debug builds, and the Dev Server itself is +started by the IDE. (`.UseStudio()` is not needed either — that enables **Hot +Design**, Uno's runtime *XAML* designer, which doesn't apply to a XAML-free +framework.) + +One caveat: from the CLI, `dotnet watch -f ` only works against a +**single-targeted** head — see +[the Hot Reload section in the port README](../../src/Reactor.Uno/README.md#hot-reload). diff --git a/samples/Uno/ReactorUnoCounter/Platforms/Android/AndroidManifest.xml b/samples/Uno/ReactorUnoCounter/Platforms/Android/AndroidManifest.xml new file mode 100644 index 000000000..95ae07533 --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/samples/Uno/ReactorUnoCounter/Platforms/Android/Main.Android.cs b/samples/Uno/ReactorUnoCounter/Platforms/Android/Main.Android.cs new file mode 100644 index 000000000..202b951c7 --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/Android/Main.Android.cs @@ -0,0 +1,22 @@ +using System; +using Android.Runtime; +using Microsoft.UI.Reactor; + +namespace ReactorUnoCounter.Droid; + +[global::Android.App.ApplicationAttribute( + Label = "@string/ApplicationName", + LargeHeap = true, + HardwareAccelerated = true, + Theme = "@style/AppTheme" +)] +public class Application : Microsoft.UI.Xaml.NativeApplication +{ + public Application(IntPtr javaReference, JniHandleOwnership transfer) + : base( + () => ReactorApp.CreateApplication("Reactor Counter (Uno)"), + javaReference, + transfer) + { + } +} diff --git a/samples/Uno/ReactorUnoCounter/Platforms/Android/MainActivity.Android.cs b/samples/Uno/ReactorUnoCounter/Platforms/Android/MainActivity.Android.cs new file mode 100644 index 000000000..cfd12e0c0 --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/Android/MainActivity.Android.cs @@ -0,0 +1,13 @@ +using Android.App; +using Android.Views; + +namespace ReactorUnoCounter.Droid; + +[Activity( + MainLauncher = true, + ConfigurationChanges = global::Uno.UI.ActivityHelper.AllConfigChanges, + WindowSoftInputMode = SoftInput.AdjustNothing | SoftInput.StateHidden +)] +public class MainActivity : Microsoft.UI.Xaml.ApplicationActivity +{ +} diff --git a/samples/Uno/ReactorUnoCounter/Platforms/Android/Resources/values/Strings.xml b/samples/Uno/ReactorUnoCounter/Platforms/Android/Resources/values/Strings.xml new file mode 100644 index 000000000..c4d5197c9 --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/Android/Resources/values/Strings.xml @@ -0,0 +1,4 @@ + + + Reactor Counter + diff --git a/samples/Uno/ReactorUnoCounter/Platforms/Android/Resources/values/Styles.xml b/samples/Uno/ReactorUnoCounter/Platforms/Android/Resources/values/Styles.xml new file mode 100644 index 000000000..37480ae0e --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/Android/Resources/values/Styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/samples/Uno/ReactorUnoCounter/Platforms/Android/environment.conf b/samples/Uno/ReactorUnoCounter/Platforms/Android/environment.conf new file mode 100644 index 000000000..af32f98a0 --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/Android/environment.conf @@ -0,0 +1,2 @@ +# See http://developer.xamarin.com/guides/android/advanced_topics/garbage_collection/ +MONO_GC_PARAMS=bridge-implementation=new,nursery-size=32m,soft-heap-limit=256m diff --git a/samples/Uno/ReactorUnoCounter/Platforms/iOS/Entitlements.plist b/samples/Uno/ReactorUnoCounter/Platforms/iOS/Entitlements.plist new file mode 100644 index 000000000..24c310368 --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/iOS/Entitlements.plist @@ -0,0 +1,6 @@ + + + + + + diff --git a/samples/Uno/ReactorUnoCounter/Platforms/iOS/Info.plist b/samples/Uno/ReactorUnoCounter/Platforms/iOS/Info.plist new file mode 100644 index 000000000..ea3dcb4bc --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/iOS/Info.plist @@ -0,0 +1,43 @@ + + + + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + armv7 + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + XSAppIconAssets + Assets.xcassets/icon.appiconset + UIApplicationSupportsIndirectInputEvents + + + + + diff --git a/samples/Uno/ReactorUnoCounter/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json b/samples/Uno/ReactorUnoCounter/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json new file mode 100644 index 000000000..69555e440 --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json @@ -0,0 +1,58 @@ +{ + "images": [ + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "2x", + "size": "640x960", + "idiom": "iphone" + }, + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "subtype": "retina4", + "scale": "2x", + "size": "640x1136", + "idiom": "iphone" + }, + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "1x", + "size": "768x1024", + "idiom": "ipad" + }, + { + "orientation": "landscape", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "1x", + "size": "1024x768", + "idiom": "ipad" + }, + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "2x", + "size": "1536x2048", + "idiom": "ipad" + }, + { + "orientation": "landscape", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "2x", + "size": "2048x1536", + "idiom": "ipad" + } + ], + "properties": {}, + "info": { + "version": 1, + "author": "" + } +} \ No newline at end of file diff --git a/samples/Uno/ReactorUnoCounter/Platforms/iOS/PrivacyInfo.xcprivacy b/samples/Uno/ReactorUnoCounter/Platforms/iOS/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..902abb059 --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Platforms/iOS/PrivacyInfo.xcprivacy @@ -0,0 +1,41 @@ + + + + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + diff --git a/samples/Uno/ReactorUnoCounter/Program.cs b/samples/Uno/ReactorUnoCounter/Program.cs new file mode 100644 index 000000000..04eb32c38 --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/Program.cs @@ -0,0 +1,36 @@ +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using static Microsoft.UI.Reactor.Factories; + +// Reactor owns the Uno Application + window + render loop. This single call is +// the whole entry point — the same shape as the Windows/WinUI ReactorApp.Run, +// and it is what desktop AND iOS use (Uno gives the Apple heads the same +// host-builder shape as desktop). +// +// Two targets differ: +// - WebAssembly can't block the browser thread, so it uses the async entry. +// - Android has no console entry point at all — the OS starts the Activity in +// Platforms/Android/, which calls ReactorApp.CreateApplication(). +#if __ANDROID__ +// intentionally empty — see Platforms/Android/ +#elif __WASM__ +await ReactorApp.RunAsync("Reactor Counter (Uno)", width: 480, height: 360); +#else +ReactorApp.Run("Reactor Counter (Uno)", width: 480, height: 360); +#endif + +class CounterApp : Component +{ + public override Element Render() + { + var (count, setCount) = UseState(0); + + return VStack(12, + Heading($"Count: {count}"), + HStack(8, + Button("-", () => setCount(count - 1)), + Button("+", () => setCount(count + 1)) + ) + ).Padding(24); + } +} diff --git a/samples/Uno/ReactorUnoCounter/ReactorUnoCounter.csproj b/samples/Uno/ReactorUnoCounter/ReactorUnoCounter.csproj new file mode 100644 index 000000000..1da6b16fd --- /dev/null +++ b/samples/Uno/ReactorUnoCounter/ReactorUnoCounter.csproj @@ -0,0 +1,33 @@ + + + + net10.0-desktop;net10.0-browserwasm;net10.0-android;net10.0-ios + Exe + true + + Reactor Counter (Uno) + dev.reactor.unocounter + 1.0 + 1 + + enable + + + true + + + + SkiaRenderer; + + + + + + + + diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/Android/AndroidManifest.xml b/samples/Uno/ReactorUnoShowcase/Platforms/Android/AndroidManifest.xml new file mode 100644 index 000000000..95ae07533 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/Android/Main.Android.cs b/samples/Uno/ReactorUnoShowcase/Platforms/Android/Main.Android.cs new file mode 100644 index 000000000..1184721d5 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/Android/Main.Android.cs @@ -0,0 +1,22 @@ +using System; +using Android.Runtime; +using Microsoft.UI.Reactor; + +namespace ReactorUnoShowcase.Droid; + +[global::Android.App.ApplicationAttribute( + Label = "@string/ApplicationName", + LargeHeap = true, + HardwareAccelerated = true, + Theme = "@style/AppTheme" +)] +public class Application : Microsoft.UI.Xaml.NativeApplication +{ + public Application(IntPtr javaReference, JniHandleOwnership transfer) + : base( + () => ReactorApp.CreateApplication("Reactor Showcase (Uno)"), + javaReference, + transfer) + { + } +} diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/Android/MainActivity.Android.cs b/samples/Uno/ReactorUnoShowcase/Platforms/Android/MainActivity.Android.cs new file mode 100644 index 000000000..c9cfe5886 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/Android/MainActivity.Android.cs @@ -0,0 +1,13 @@ +using Android.App; +using Android.Views; + +namespace ReactorUnoShowcase.Droid; + +[Activity( + MainLauncher = true, + ConfigurationChanges = global::Uno.UI.ActivityHelper.AllConfigChanges, + WindowSoftInputMode = SoftInput.AdjustNothing | SoftInput.StateHidden +)] +public class MainActivity : Microsoft.UI.Xaml.ApplicationActivity +{ +} diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/Android/Resources/values/Strings.xml b/samples/Uno/ReactorUnoShowcase/Platforms/Android/Resources/values/Strings.xml new file mode 100644 index 000000000..fc64af311 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/Android/Resources/values/Strings.xml @@ -0,0 +1,4 @@ + + + Reactor Showcase + diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/Android/Resources/values/Styles.xml b/samples/Uno/ReactorUnoShowcase/Platforms/Android/Resources/values/Styles.xml new file mode 100644 index 000000000..37480ae0e --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/Android/Resources/values/Styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/Android/environment.conf b/samples/Uno/ReactorUnoShowcase/Platforms/Android/environment.conf new file mode 100644 index 000000000..af32f98a0 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/Android/environment.conf @@ -0,0 +1,2 @@ +# See http://developer.xamarin.com/guides/android/advanced_topics/garbage_collection/ +MONO_GC_PARAMS=bridge-implementation=new,nursery-size=32m,soft-heap-limit=256m diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/iOS/Entitlements.plist b/samples/Uno/ReactorUnoShowcase/Platforms/iOS/Entitlements.plist new file mode 100644 index 000000000..24c310368 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/iOS/Entitlements.plist @@ -0,0 +1,6 @@ + + + + + + diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/iOS/Info.plist b/samples/Uno/ReactorUnoShowcase/Platforms/iOS/Info.plist new file mode 100644 index 000000000..ea3dcb4bc --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/iOS/Info.plist @@ -0,0 +1,43 @@ + + + + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + armv7 + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + XSAppIconAssets + Assets.xcassets/icon.appiconset + UIApplicationSupportsIndirectInputEvents + + + + + diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json b/samples/Uno/ReactorUnoShowcase/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json new file mode 100644 index 000000000..69555e440 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json @@ -0,0 +1,58 @@ +{ + "images": [ + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "2x", + "size": "640x960", + "idiom": "iphone" + }, + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "subtype": "retina4", + "scale": "2x", + "size": "640x1136", + "idiom": "iphone" + }, + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "1x", + "size": "768x1024", + "idiom": "ipad" + }, + { + "orientation": "landscape", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "1x", + "size": "1024x768", + "idiom": "ipad" + }, + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "2x", + "size": "1536x2048", + "idiom": "ipad" + }, + { + "orientation": "landscape", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "2x", + "size": "2048x1536", + "idiom": "ipad" + } + ], + "properties": {}, + "info": { + "version": 1, + "author": "" + } +} \ No newline at end of file diff --git a/samples/Uno/ReactorUnoShowcase/Platforms/iOS/PrivacyInfo.xcprivacy b/samples/Uno/ReactorUnoShowcase/Platforms/iOS/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..902abb059 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Platforms/iOS/PrivacyInfo.xcprivacy @@ -0,0 +1,41 @@ + + + + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + diff --git a/samples/Uno/ReactorUnoShowcase/Program.cs b/samples/Uno/ReactorUnoShowcase/Program.cs new file mode 100644 index 000000000..877537b39 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/Program.cs @@ -0,0 +1,117 @@ +// Secondary windows are real on every Uno *desktop* head (X11 / Win32 / macOS / +// FrameBuffer) but throw InvalidOperationException on Android and iOS, and there +// are no OS windows in the browser at all. So the multi-window demo is gated on +// "desktop", not merely "not wasm". +#if !__WASM__ && !__ANDROID__ && !__IOS__ && !__MACCATALYST__ +#define REACTOR_DESKTOP +#endif + +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using static Microsoft.UI.Reactor.Factories; + +// Desktop and iOS share ReactorApp.Run; wasm needs the async entry (the browser +// thread can't block); Android has no console entry point and starts from the +// Activity in Platforms/Android/. +#if __ANDROID__ +// intentionally empty — see Platforms/Android/ +#elif __WASM__ +await ReactorApp.RunAsync("Reactor Showcase (Uno)", width: 560, height: 620); +#else +ReactorApp.Run("Reactor Showcase (Uno)", width: 560, height: 620); +#endif + +class Showcase : Component +{ + static readonly string[] Fruits = { "Donuts", "Apples", "Bananas" }; + + public override Element Render() + { + var (on, setOn) = UseState(true); + var (slider, setSlider) = UseState(40.0); + var (chk, setChk) = UseState(true); + var (combo, setCombo) = UseState(0); + var (count, setCount) = UseState(0); + var (picked, setPicked) = UseState("(none)"); + + return ScrollView( + VStack(14, + Heading("Reactor on Uno — Control Showcase"), + + TextBlock($"Counter: {count}"), + HStack(8, + Button("-", () => setCount(count - 1)), + Button("+", () => setCount(count + 1))), + + ToggleSwitch(on, setOn, header: "Feature toggle"), + TextBlock(on ? "Feature is ON" : "Feature is OFF"), + + TextBlock($"Slider value: {slider:0}"), + Slider(slider, 0, 100, setSlider), + Progress(slider), + + CheckBox(chk, b => setChk(b), label: "I agree"), + TextBlock(chk == true ? "Checked" : "Unchecked"), + + TextBlock($"Favourite: {Fruits[combo]}"), + ComboBox(Fruits, combo, setCombo), + + // Uno implements the WinRT pickers, including the + // WindowNative.GetWindowHandle + InitializeWithWindow association + // the shared hook performs — the very pattern Uno's own docs + // prescribe. Nothing Windows-only here. + Heading("File picker"), + TextBlock($"Picked: {picked}"), + Button("Pick a file…", async () => + { + var file = await UseFilePickerAsync(new FilePickerOptions()); + setPicked(file?.Name ?? "(cancelled)"); + }) +#if REACTOR_DESKTOP + , + Heading("Multi-window"), + TextBlock("Each window gets its own Reactor host, render loop, and state."), + Button("Open a second window", OpenSecondWindow) +#endif + ).Padding(24) + ); + } + +#if REACTOR_DESKTOP + // Secondary windows are real on every Uno desktop head (X11 / Win32 / macOS / + // FrameBuffer). Android and iOS throw InvalidOperationException instead. + static void OpenSecondWindow() => + ReactorApp.OpenWindow( + new WindowSpec { Title = "Second Window", Width = 380, Height = 260 }, + static () => new SecondWindow()); +#endif +} + +#if REACTOR_DESKTOP +class SecondWindow : Component +{ + public override Element Render() + { + var (count, setCount) = UseState(0); + var (locked, setLocked) = UseState(false); + + // A real closing guard, backed by Uno's AppWindow.Closing on the desktop + // heads: tick the box and the window refuses to close. + UseClosingGuard(() => locked != true); + + return VStack(12, + Heading("Second window 🎉"), + TextBlock("Independent of the main window:"), + TextBlock($"Count: {count}"), + HStack(8, + Button("-", () => setCount(count - 1)), + Button("+", () => setCount(count + 1)) + ), + CheckBox(locked, b => setLocked(b), label: "Prevent closing"), + TextBlock(locked == true + ? "Close is blocked — untick to close." + : "Close is allowed.") + ).Padding(24); + } +} +#endif diff --git a/samples/Uno/ReactorUnoShowcase/ReactorUnoShowcase.csproj b/samples/Uno/ReactorUnoShowcase/ReactorUnoShowcase.csproj new file mode 100644 index 000000000..8d428d695 --- /dev/null +++ b/samples/Uno/ReactorUnoShowcase/ReactorUnoShowcase.csproj @@ -0,0 +1,31 @@ + + + + net10.0-desktop;net10.0-browserwasm;net10.0-android;net10.0-ios + Exe + true + + Reactor Showcase (Uno) + dev.reactor.unoshowcase + 1.0 + 1 + enable + + + true + + + + SkiaRenderer; + + + + + + + + diff --git a/samples/Uno/file-based/Counter.cs b/samples/Uno/file-based/Counter.cs new file mode 100644 index 000000000..bf9c52057 --- /dev/null +++ b/samples/Uno/file-based/Counter.cs @@ -0,0 +1,35 @@ +#:sdk Uno.Sdk@6.7.0-dev.117 +#:project ../../../src/Reactor.Uno/Reactor.Uno.csproj +#:property TargetFramework=net10.0-desktop +#:property OutputType=Exe +#:property UnoSingleProject=true +#:property UnoFeatures=SkiaRenderer +#:property PublishAot=false +#:property PackAsTool=false + +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using static Microsoft.UI.Reactor.Factories; + +// A whole WinUI-style Reactor app — in one .cs file — running on Uno Platform. +// Run it with: dotnet run Counter.cs +ReactorApp.Run("Single-File Reactor on Uno", width: 520, height: 400); + +class CounterApp : Component +{ + public override Element Render() + { + var (name, setName) = UseState("Uno"); + var (count, setCount) = UseState(0); + + return VStack(12, + Heading($"Hello, {name}!"), + TextBox(name, setName, placeholderText: "Your name"), + Heading($"Count: {count}"), + HStack(8, + Button("-", () => setCount(count - 1)), + Button("+", () => setCount(count + 1)) + ) + ).Padding(24); + } +} diff --git a/samples/Uno/global.json b/samples/Uno/global.json new file mode 100644 index 000000000..4e96f889a --- /dev/null +++ b/samples/Uno/global.json @@ -0,0 +1,10 @@ +{ + "msbuild-sdks": { + "Uno.Sdk": "6.7.0-dev.117" + }, + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/samples/Uno/nuget.config b/samples/Uno/nuget.config new file mode 100644 index 000000000..e21d5c691 --- /dev/null +++ b/samples/Uno/nuget.config @@ -0,0 +1,13 @@ + + + + + + + + + + + From e082ce013012bb7bc0af7d1fffd65949714c304e Mon Sep 17 00:00:00 2001 From: Martin Zikmund Date: Thu, 6 Aug 2026 13:47:48 +0200 Subject: [PATCH 4/5] ci(uno): add a path-gated build leg for the Uno port MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate from ci.yml on purpose: the main CI drives the Windows / Windows App SDK build and must never restore Uno.Sdk. This leg restores Uno.Sdk and builds Reactor.Uno.slnx per-TFM across all four heads — desktop and WebAssembly in one job, Android and iOS in a second because they need extra workloads. Path-gated on the port itself AND on the source it shares (src/Reactor, src/Reactor.Advanced), because a change there can break the Uno build without touching a single file under src/Reactor.Uno. That is not hypothetical: moving the charting/markdown/data-grid subsystems into Reactor.Advanced silently dropped 74 files out of the port. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017rs9N4ZH4ss5tDtBwBN1Go --- .github/workflows/ci-uno.yml | 98 ++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/ci-uno.yml diff --git a/.github/workflows/ci-uno.yml b/.github/workflows/ci-uno.yml new file mode 100644 index 000000000..fb516fb0d --- /dev/null +++ b/.github/workflows/ci-uno.yml @@ -0,0 +1,98 @@ +name: CI (Uno) + +# Build gate for the Uno Platform port (src/Reactor.Uno + samples/Uno). +# +# Kept separate from ci.yml on purpose: the main CI drives the Windows / Windows +# App SDK build against Reactor.slnx and must never restore Uno.Sdk. This leg +# restores Uno.Sdk (from nuget.org, incl. the current 6.7.0-dev.* build the port +# pins) and builds Reactor.Uno.slnx for desktop and WebAssembly, plus Android and +# iOS in a second job. It is path-gated so it only runs when the Uno port or the +# shared source it source-shares (src/Reactor, src/Reactor.Advanced) changes. +# +# Both samples are Uno single-projects targeting all four heads, so the whole +# solution builds per-TFM for desktop / wasm / android / ios. Android and iOS run +# in a second job because they need extra workloads. See issue #2033. + +on: + pull_request: + paths: + - 'src/Reactor.Uno/**' + - 'samples/Uno/**' + - 'src/Reactor/**' + # The port also source-shares Charting/, Markdown/ and the DataGrid from + # Reactor.Advanced (spec 062 Track B moved them out of core), so changes + # there can break the Uno build exactly like changes to src/Reactor. + - 'src/Reactor.Advanced/**' + - 'Reactor.Uno.slnx' + - 'global.json' + - '.github/workflows/ci-uno.yml' + push: + branches: [main] + paths: + - 'src/Reactor.Uno/**' + - 'samples/Uno/**' + - 'src/Reactor/**' + # The port also source-shares Charting/, Markdown/ and the DataGrid from + # Reactor.Advanced (spec 062 Track B moved them out of core), so changes + # there can break the Uno build exactly like changes to src/Reactor. + - 'src/Reactor.Advanced/**' + - 'Reactor.Uno.slnx' + - 'global.json' + - '.github/workflows/ci-uno.yml' + workflow_dispatch: + +concurrency: + group: ci-uno-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + build-uno: + name: Build Reactor.Uno (desktop + wasm) + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.0.x + + # Required for the net10.0-browserwasm target. + - name: Install wasm-tools workload + run: dotnet workload install wasm-tools + + # Per-TFM builds (not a bare slnx build) so the library's net10.0-android + # TFM is skipped — desktop and wasm are the sample-backed, validated targets. + - name: Build desktop + run: dotnet build Reactor.Uno.slnx -f net10.0-desktop -c Debug + + - name: Build WebAssembly + run: dotnet build Reactor.Uno.slnx -f net10.0-browserwasm -c Debug + + build-mobile: + name: Build Reactor.Uno (android + ios) + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Setup .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0 + with: + dotnet-version: 10.0.x + + - name: Install mobile workloads + run: dotnet workload install android ios + + # Produces a signed APK for both samples, so the Android head cannot rot. + - name: Build Android + run: dotnet build Reactor.Uno.slnx -f net10.0-android -c Debug + + # Managed compile only -- a device build/deploy needs a Mac, but this keeps + # the iOS head honest. + - name: Build iOS + run: dotnet build Reactor.Uno.slnx -f net10.0-ios -c Debug From 89c7f56b437234b092b15fd05724214cf5ff71de Mon Sep 17 00:00:00 2001 From: Martin Zikmund Date: Thu, 6 Aug 2026 13:48:05 +0200 Subject: [PATCH 5/5] =?UTF-8?q?feat(uno):=20add=20ReactorGalleryUno=20?= =?UTF-8?q?=E2=80=94=20the=20WinUI=20gallery=20on=20all=20four=20Uno=20hea?= =?UTF-8?q?ds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Source-shares ~10k lines from samples/ReactorGallery verbatim rather than forking, so the two galleries cannot drift. Of 108 gallery files, all but three compile against Uno untouched; each exception has a documented Shims/ counterpart so the shared source needs no edits at all: - GalleryActivation / GalleryProtocol — Windows App SDK AppInstance activation and Win32 HKCU protocol registration. The stand-ins report "no activation" and "not registered" honestly rather than faking success. GalleryRoutes.cs and GalleryActivationRouting.cs are pure routing and ARE shared. - DockingPage — docking is Win32-native and excluded from the port; PageRouter still routes the tag, so the stand-in explains the gap instead of 404-ing. - WebView2Page — on Uno `WebView2` resolves to a namespace that shadows Reactor's WebView2(...) factory, and a namespace-vs-method-group shadow cannot be disambiguated by an alias. A naming collision in the sample, NOT a missing Uno feature; worth fixing upstream so this page can be shared too. Builds 0 errors on desktop / wasm / android / ios, and runs on desktop: the nav tree, page routing and the control pages all render. KNOWN RENDERING GAPS (compilation is clean; these are runtime): - NavigationView stays in overlay display mode with a scrim over the content at any window width, instead of switching to Expanded as it does on Windows. - The Home page's category and "Recently Added" cards do not render. Both are unexplained and want investigation before this is used as a showcase. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017rs9N4ZH4ss5tDtBwBN1Go --- .github/workflows/ci-uno.yml | 4 + Reactor.Uno.slnx | 1 + samples/Uno/ReactorGalleryUno/GlobalUsings.cs | 11 +++ .../Platforms/Android/AndroidManifest.xml | 4 + .../Platforms/Android/Main.Android.cs | 23 +++++ .../Platforms/Android/MainActivity.Android.cs | 13 +++ .../Android/Resources/values/Strings.xml | 4 + .../Android/Resources/values/Styles.xml | 11 +++ .../Platforms/Android/environment.conf | 2 + .../Platforms/iOS/Entitlements.plist | 6 ++ .../Platforms/iOS/Info.plist | 43 +++++++++ .../LaunchImages.launchimage/Contents.json | 58 ++++++++++++ .../Platforms/iOS/PrivacyInfo.xcprivacy | 41 +++++++++ samples/Uno/ReactorGalleryUno/Program.cs | 24 +++++ .../ReactorGalleryUno.csproj | 90 +++++++++++++++++++ .../ReactorGalleryUno/Shims/DockingPage.cs | 27 ++++++ .../Shims/GalleryDeepLinkShims.cs | 80 +++++++++++++++++ .../ReactorGalleryUno/Shims/WebView2Page.cs | 31 +++++++ 18 files changed, 473 insertions(+) create mode 100644 samples/Uno/ReactorGalleryUno/GlobalUsings.cs create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/Android/AndroidManifest.xml create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/Android/Main.Android.cs create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/Android/MainActivity.Android.cs create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/Android/Resources/values/Strings.xml create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/Android/Resources/values/Styles.xml create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/Android/environment.conf create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/iOS/Entitlements.plist create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/iOS/Info.plist create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json create mode 100644 samples/Uno/ReactorGalleryUno/Platforms/iOS/PrivacyInfo.xcprivacy create mode 100644 samples/Uno/ReactorGalleryUno/Program.cs create mode 100644 samples/Uno/ReactorGalleryUno/ReactorGalleryUno.csproj create mode 100644 samples/Uno/ReactorGalleryUno/Shims/DockingPage.cs create mode 100644 samples/Uno/ReactorGalleryUno/Shims/GalleryDeepLinkShims.cs create mode 100644 samples/Uno/ReactorGalleryUno/Shims/WebView2Page.cs diff --git a/.github/workflows/ci-uno.yml b/.github/workflows/ci-uno.yml index fb516fb0d..b301f6aff 100644 --- a/.github/workflows/ci-uno.yml +++ b/.github/workflows/ci-uno.yml @@ -23,6 +23,8 @@ on: # Reactor.Advanced (spec 062 Track B moved them out of core), so changes # there can break the Uno build exactly like changes to src/Reactor. - 'src/Reactor.Advanced/**' + # The Uno gallery source-shares ~10k lines from the Windows gallery. + - 'samples/ReactorGallery/**' - 'Reactor.Uno.slnx' - 'global.json' - '.github/workflows/ci-uno.yml' @@ -36,6 +38,8 @@ on: # Reactor.Advanced (spec 062 Track B moved them out of core), so changes # there can break the Uno build exactly like changes to src/Reactor. - 'src/Reactor.Advanced/**' + # The Uno gallery source-shares ~10k lines from the Windows gallery. + - 'samples/ReactorGallery/**' - 'Reactor.Uno.slnx' - 'global.json' - '.github/workflows/ci-uno.yml' diff --git a/Reactor.Uno.slnx b/Reactor.Uno.slnx index 35c9fdbd8..a1a206677 100644 --- a/Reactor.Uno.slnx +++ b/Reactor.Uno.slnx @@ -23,5 +23,6 @@ + diff --git a/samples/Uno/ReactorGalleryUno/GlobalUsings.cs b/samples/Uno/ReactorGalleryUno/GlobalUsings.cs new file mode 100644 index 000000000..72f2947fe --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/GlobalUsings.cs @@ -0,0 +1,11 @@ +// Name disambiguation for the source-shared gallery pages. +// +// Every gallery page carries `using Microsoft.UI.Xaml.Controls;` alongside +// `using static Microsoft.UI.Reactor.Factories;`. On Uno that pulls Uno's own +// SelectionMode into scope next to Reactor's, and the shared source refers to +// the bare name — so pin it here rather than forking the pages. +// +// Reactor's Reactor.Uno library does exactly the same thing for the same reason +// (see src/Reactor.Uno/GlobalUsings.cs). + +global using SelectionMode = Microsoft.UI.Reactor.Controls.SelectionMode; diff --git a/samples/Uno/ReactorGalleryUno/Platforms/Android/AndroidManifest.xml b/samples/Uno/ReactorGalleryUno/Platforms/Android/AndroidManifest.xml new file mode 100644 index 000000000..95ae07533 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/samples/Uno/ReactorGalleryUno/Platforms/Android/Main.Android.cs b/samples/Uno/ReactorGalleryUno/Platforms/Android/Main.Android.cs new file mode 100644 index 000000000..6bcbb7f03 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/Android/Main.Android.cs @@ -0,0 +1,23 @@ +using System; +using Android.Runtime; +using Microsoft.UI.Reactor; +using WinUIGalleryReactor; + +namespace ReactorGalleryUno.Droid; + +[global::Android.App.ApplicationAttribute( + Label = "@string/ApplicationName", + LargeHeap = true, + HardwareAccelerated = true, + Theme = "@style/AppTheme" +)] +public class Application : Microsoft.UI.Xaml.NativeApplication +{ + public Application(IntPtr javaReference, JniHandleOwnership transfer) + : base( + () => ReactorApp.CreateApplication("Reactor Gallery (Uno)"), + javaReference, + transfer) + { + } +} diff --git a/samples/Uno/ReactorGalleryUno/Platforms/Android/MainActivity.Android.cs b/samples/Uno/ReactorGalleryUno/Platforms/Android/MainActivity.Android.cs new file mode 100644 index 000000000..a9da47403 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/Android/MainActivity.Android.cs @@ -0,0 +1,13 @@ +using Android.App; +using Android.Views; + +namespace ReactorGalleryUno.Droid; + +[Activity( + MainLauncher = true, + ConfigurationChanges = global::Uno.UI.ActivityHelper.AllConfigChanges, + WindowSoftInputMode = SoftInput.AdjustNothing | SoftInput.StateHidden +)] +public class MainActivity : Microsoft.UI.Xaml.ApplicationActivity +{ +} diff --git a/samples/Uno/ReactorGalleryUno/Platforms/Android/Resources/values/Strings.xml b/samples/Uno/ReactorGalleryUno/Platforms/Android/Resources/values/Strings.xml new file mode 100644 index 000000000..c153ddd7c --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/Android/Resources/values/Strings.xml @@ -0,0 +1,4 @@ + + + Reactor Gallery + diff --git a/samples/Uno/ReactorGalleryUno/Platforms/Android/Resources/values/Styles.xml b/samples/Uno/ReactorGalleryUno/Platforms/Android/Resources/values/Styles.xml new file mode 100644 index 000000000..37480ae0e --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/Android/Resources/values/Styles.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/samples/Uno/ReactorGalleryUno/Platforms/Android/environment.conf b/samples/Uno/ReactorGalleryUno/Platforms/Android/environment.conf new file mode 100644 index 000000000..af32f98a0 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/Android/environment.conf @@ -0,0 +1,2 @@ +# See http://developer.xamarin.com/guides/android/advanced_topics/garbage_collection/ +MONO_GC_PARAMS=bridge-implementation=new,nursery-size=32m,soft-heap-limit=256m diff --git a/samples/Uno/ReactorGalleryUno/Platforms/iOS/Entitlements.plist b/samples/Uno/ReactorGalleryUno/Platforms/iOS/Entitlements.plist new file mode 100644 index 000000000..24c310368 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/iOS/Entitlements.plist @@ -0,0 +1,6 @@ + + + + + + diff --git a/samples/Uno/ReactorGalleryUno/Platforms/iOS/Info.plist b/samples/Uno/ReactorGalleryUno/Platforms/iOS/Info.plist new file mode 100644 index 000000000..ea3dcb4bc --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/iOS/Info.plist @@ -0,0 +1,43 @@ + + + + + LSRequiresIPhoneOS + + UIDeviceFamily + + 1 + 2 + + UIRequiredDeviceCapabilities + + armv7 + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + XSAppIconAssets + Assets.xcassets/icon.appiconset + UIApplicationSupportsIndirectInputEvents + + + + + diff --git a/samples/Uno/ReactorGalleryUno/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json b/samples/Uno/ReactorGalleryUno/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json new file mode 100644 index 000000000..69555e440 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/iOS/Media.xcassets/LaunchImages.launchimage/Contents.json @@ -0,0 +1,58 @@ +{ + "images": [ + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "2x", + "size": "640x960", + "idiom": "iphone" + }, + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "subtype": "retina4", + "scale": "2x", + "size": "640x1136", + "idiom": "iphone" + }, + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "1x", + "size": "768x1024", + "idiom": "ipad" + }, + { + "orientation": "landscape", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "1x", + "size": "1024x768", + "idiom": "ipad" + }, + { + "orientation": "portrait", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "2x", + "size": "1536x2048", + "idiom": "ipad" + }, + { + "orientation": "landscape", + "extent": "full-screen", + "minimum-system-version": "7.0", + "scale": "2x", + "size": "2048x1536", + "idiom": "ipad" + } + ], + "properties": {}, + "info": { + "version": 1, + "author": "" + } +} \ No newline at end of file diff --git a/samples/Uno/ReactorGalleryUno/Platforms/iOS/PrivacyInfo.xcprivacy b/samples/Uno/ReactorGalleryUno/Platforms/iOS/PrivacyInfo.xcprivacy new file mode 100644 index 000000000..902abb059 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Platforms/iOS/PrivacyInfo.xcprivacy @@ -0,0 +1,41 @@ + + + + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategorySystemBootTime + NSPrivacyAccessedAPITypeReasons + + 35F9.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + diff --git a/samples/Uno/ReactorGalleryUno/Program.cs b/samples/Uno/ReactorGalleryUno/Program.cs new file mode 100644 index 000000000..c4b3d7694 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Program.cs @@ -0,0 +1,24 @@ +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Hosting; +using WinUIGalleryReactor; + +// The Uno gallery head. +// +// Mirrors samples/ReactorGallery/Program.cs minus the two Windows-only steps: +// there is no `reactor-gallery://` single-instance redirection or HKCU protocol +// registration (see Shims/GalleryDeepLinkShims.cs), and no docking interop to +// register because that subsystem is not part of the Uno port. +// +// As on Windows, no explicit width/height: the gallery is content-heavy and +// benefits from the OS-chosen default window size. +#if __ANDROID__ +// intentionally empty — Android starts from the Activity in Platforms/Android/ +#elif __WASM__ +await ReactorApp.RunAsync( + "Reactor Gallery (Uno)", + configure: static host => XamlInterop.Register(host.Reconciler)); +#else +ReactorApp.Run( + "Reactor Gallery (Uno)", + configure: static host => XamlInterop.Register(host.Reconciler)); +#endif diff --git a/samples/Uno/ReactorGalleryUno/ReactorGalleryUno.csproj b/samples/Uno/ReactorGalleryUno/ReactorGalleryUno.csproj new file mode 100644 index 000000000..bc6d49e8f --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/ReactorGalleryUno.csproj @@ -0,0 +1,90 @@ + + + + net10.0-desktop;net10.0-browserwasm;net10.0-android;net10.0-ios + + Exe + true + + Reactor Gallery (Uno) + dev.reactor.gallery + 1.0 + 1 + + + WinUIGalleryReactor + enable + enable + + + true + + + SkiaRenderer; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/Uno/ReactorGalleryUno/Shims/DockingPage.cs b/samples/Uno/ReactorGalleryUno/Shims/DockingPage.cs new file mode 100644 index 000000000..bbac04063 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Shims/DockingPage.cs @@ -0,0 +1,27 @@ +// Uno stand-in for the gallery's docking page. +// +// The docking subsystem (dock manager, tab tear-off, floating windows) is built +// on Win32 native windowing and is excluded from the Reactor.Uno port entirely, +// so ControlPages/Layout/DockingPage.cs cannot compile here. PageRouter is shared +// source and still routes the "docking" tag, so this page takes its place and +// says so plainly rather than 404-ing or silently rendering nothing. + +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using static Microsoft.UI.Reactor.Factories; + +namespace WinUIGalleryReactor.ControlPages.Layout; + +class DockingPage : Component +{ + public override Element Render() => + VStack(12, + Heading("Docking"), + Body("Not available on Uno Platform."), + Body( + "The dock manager, tab tear-off and floating windows are implemented " + + "against Win32 native windowing, so the docking subsystem is excluded " + + "from the Uno port. Every other page in this gallery is the exact same " + + "source as the Windows gallery.") + ).Padding(24); +} diff --git a/samples/Uno/ReactorGalleryUno/Shims/GalleryDeepLinkShims.cs b/samples/Uno/ReactorGalleryUno/Shims/GalleryDeepLinkShims.cs new file mode 100644 index 000000000..4a8ffe638 --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Shims/GalleryDeepLinkShims.cs @@ -0,0 +1,80 @@ +// Uno-side counterparts for the gallery's Windows-only deep-link plumbing. +// +// samples/ReactorGallery/DeepLink splits cleanly in two: +// * GalleryRoutes.cs / GalleryActivationRouting.cs are pure routing logic and +// are source-shared verbatim by this project; +// * GalleryActivation.cs / GalleryProtocol.cs / GalleryPackageIdentity.cs are +// Windows App SDK (Microsoft.Windows.AppLifecycle.AppInstance) and Win32 +// (LibraryImport + HKCU registry), which have no Uno equivalent. +// +// Rather than fork GalleryShell.cs and SettingsPage.cs to remove their calls, +// this file re-declares the same types with the same surface so ALL gallery +// source compiles unchanged. The semantics are honest, not fake: on Uno the app +// is never launched by a `reactor-gallery://` link, so there is no initial route, +// no activation event ever fires, and protocol registration reports "not +// registered" and refuses to register. + +using System; +using System.Diagnostics.CodeAnalysis; + +namespace WinUIGalleryReactor; + +/// +/// Uno stand-in for the WinAppSDK single-instance activation handler. +/// Deep-link activation needs AppInstance.FindOrRegisterForKey / +/// GetActivatedEventArgs, which are Windows App SDK APIs; nothing +/// equivalent exists across the Uno heads, so this reports "no activation". +/// +public static class GalleryActivation +{ + /// Always null on Uno: the app is never protocol-activated. + public static GalleryRoute? InitialRoute => null; + + /// + /// Never raised on Uno. Declared so GalleryShell's subscribe/unsubscribe + /// effect compiles and runs unchanged. + /// +#pragma warning disable CS0067 // part of the shared API surface; intentionally never raised here + public static event Action? RouteActivated; +#pragma warning restore CS0067 + + /// Always false — there is never a pending warm-start route. + public static bool TryTakePendingRoute([NotNullWhen(true)] out GalleryRoute? route) + { + route = null; + return false; + } + + /// + /// Always false — no single-instance redirection, so startup continues + /// normally and opens this instance's window. + /// + public static bool TryRedirectToRunningInstance() => false; +} + +/// +/// Uno stand-in for the reactor-gallery:// protocol registration. +/// The Windows build either declares the scheme in its MSIX manifest or writes +/// an HKCU registration via Win32; neither is available (or meaningful) here, so +/// registration is reported as unsupported rather than pretended. +/// +public static class GalleryProtocol +{ + /// Uno heads have no MSIX package identity. + public static bool IsPackaged => false; + + /// Nothing manages the scheme on Uno, so the Settings page shows the toggle as off and inert. + public static bool IsManagedByPackage => false; + + /// Always false: the scheme is never registered on Uno. + public static bool IsRegistered => false; + + /// No-op; returns false to report "not registered". + public static bool EnsureRegistered() => false; + + /// Unsupported on Uno; returns false. + public static bool Register() => false; + + /// Unsupported on Uno; returns false. + public static bool Unregister() => false; +} diff --git a/samples/Uno/ReactorGalleryUno/Shims/WebView2Page.cs b/samples/Uno/ReactorGalleryUno/Shims/WebView2Page.cs new file mode 100644 index 000000000..0e12312ba --- /dev/null +++ b/samples/Uno/ReactorGalleryUno/Shims/WebView2Page.cs @@ -0,0 +1,31 @@ +// Uno stand-in for the gallery's WebView2 page. +// +// This is a *compilation* limitation, not a capability one. On Uno the name +// `WebView2` resolves to a namespace, which shadows Reactor's `WebView2(...)` +// element factory that the shared page calls — and a namespace shadowing a +// method group is not something a `global using` alias can disambiguate. The +// page is therefore excluded and replaced here, rather than forking it. +// +// Uno does implement a WebView2 control, so a fully-qualified variant of the +// shared page would work; that is worth revisiting upstream so both galleries +// can keep using one file. + +using Microsoft.UI.Reactor; +using Microsoft.UI.Reactor.Core; +using static Microsoft.UI.Reactor.Factories; + +namespace WinUIGalleryReactor.ControlPages.Media; + +class WebView2Page : Component +{ + public override Element Render() => + VStack(12, + Heading("WebView2"), + Body("Not shown in the Uno gallery."), + Body( + "Reactor's WebView2(...) factory is shadowed by a namespace of the " + + "same name on Uno, so the shared gallery page does not compile here. " + + "Uno itself does implement WebView2 — this is a naming collision in " + + "the sample, not a missing platform feature.") + ).Padding(24); +}