diff --git a/.github/workflows/ci-uno.yml b/.github/workflows/ci-uno.yml new file mode 100644 index 000000000..b301f6aff --- /dev/null +++ b/.github/workflows/ci-uno.yml @@ -0,0 +1,102 @@ +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/**' + # The Uno gallery source-shares ~10k lines from the Windows gallery. + - 'samples/ReactorGallery/**' + - '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/**' + # The Uno gallery source-shares ~10k lines from the Windows gallery. + - 'samples/ReactorGallery/**' + - '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 diff --git a/Reactor.Uno.slnx b/Reactor.Uno.slnx new file mode 100644 index 000000000..a1a206677 --- /dev/null +++ b/Reactor.Uno.slnx @@ -0,0 +1,28 @@ + + + + + + + + + + + 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/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/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); +} 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 @@ + + + + + + + + + + + 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 +
+ + + + 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)