Skip to content

Add macOS AppKit MAUI backend (platforms/MacOS)#161

Merged
jfversluis merged 18 commits into
mainfrom
redth/macos-appkit-backend
May 2, 2026
Merged

Add macOS AppKit MAUI backend (platforms/MacOS)#161
jfversluis merged 18 commits into
mainfrom
redth/macos-appkit-backend

Conversation

@Redth
Copy link
Copy Markdown
Member

@Redth Redth commented Apr 27, 2026

Brings a native macOS AppKit MAUI backend into this repo so MAUI apps can run as true AppKit apps (NSWindow, NSButton, NSScrollView, native menu bar, sidebar flyout, etc.) instead of going through Mac Catalyst. Until now the only fully-shaped backend in platforms/ was Linux.Gtk4; this PR adds a MacOS peer that follows the same conventions, plus an end-to-end sample and the docs to go with it.

The implementation is migrated from shinyorg/mauiplatforms (commit 62d4022) and refactored to the maui-labs structure. Class names (MacOS*) and the UseMauiAppMacOS<TApp>() entry point are preserved to keep upstream parity, but everything else is normalized:

Upstream (shinyorg) maui-labs
Package id Platform.Maui.MacOS[.X] Microsoft.Maui.Platforms.MacOS[.X]
Namespace Microsoft.Maui.Platform.MacOS[.Sub] Microsoft.Maui.Platforms.MacOS[.Sub]
Dispatching/, Hosting/MacOSMauiApplication.cs, Handlers/MacOSFontNamedSizeService.cs collapsed into Platform/
build/*.targets buildTransitive/*.targets

What's added

Four projects under platforms/MacOS/, all targeting net10.0-macos with SupportedOSPlatformVersion=14.0:

  • Microsoft.Maui.Platforms.MacOS (core handlers, hosting, platform services, MapKit-backed MapView)
  • Microsoft.Maui.Platforms.MacOS.Essentials (AppInfo, Battery, Clipboard, Geolocation, Preferences, SecureStorage, Sensors, etc.)
  • Microsoft.Maui.Platforms.MacOS.BlazorWebView (Blazor Hybrid via WKWebView)
  • MacOS.Sample — comprehensive ~30-page programmatic sample covering controls, layouts, navigation, gestures, graphics, Blazor hybrid, MapKit, multi-window, theming, lifecycle, etc., with the standard EnableMauiDevFlow toggle that mirrors Linux.Gtk4.Sample.

ILLink.Descriptors.xml files were updated to reference the new assembly names so trimming preserves the public API surface.

DevFlow rewire

Previously, Microsoft.Maui.DevFlow.Agent and Microsoft.Maui.DevFlow.Blazor took a PackageReference on the upstream shinyorg NuGets when targeting net10.0-macos. Now that we have a local replacement, those switch to ProjectReference on the new in-tree projects. This:

  • Eliminates a duplicate-symbol build error (AddMacOS lifecycle extension was defined in both the local and upstream assemblies) that would otherwise hit any sample using DevFlow + MacOS.
  • Removes the dead Platform.Maui.MacOS* entries from Directory.Packages.props and the corresponding properties in eng/Versions.props.
  • Updates BlazorWebViewDebugService.cs and the DevFlow.Sample.MacOS / DevFlow.Sample code-behind files to the new namespace.

Validation

  • dotnet build platforms/MacOS/MacOS.slnx — 0 errors.
  • dotnet build platforms/MacOS/samples/MacOS.Sample/MacOS.Sample.csproj -p:EnableMauiDevFlow=true — 0 errors.
  • dotnet build samples/DevFlow.Sample.MacOS/DevFlow.Sample.MacOS.csproj — 0 errors.
  • dotnet build src/DevFlow/Microsoft.Maui.DevFlow.Agent/Microsoft.Maui.DevFlow.Agent.csproj (multi-tfm) — 0 errors.
  • dotnet build platforms/Linux.Gtk4/Linux.Gtk4.slnx — 0 errors (no regression).

Build was not exercised on a clean machine end-to-end as a dotnet run of the sample app — reviewers running on macOS 14+ should be able to launch the sample directly from platforms/MacOS/samples/MacOS.Sample/.

Notes for reviewers

  • Following the Linux.Gtk4 pattern, MacOS.slnx is standalone and is intentionally not added to the root MauiLabs.slnx.
  • Warnings in the new code are inherited from upstream (obsolete TableView/SwitchCell/EntryCell API uses in the sample, a few nullability edge cases). Not addressed in this PR to keep the diff focused on the migration.
  • The MapPage sample uses #if MACAPP from the upstream codebase; the constant is now defined in MacOS.Sample.csproj so the page compiles unchanged.

Migrates the macOS AppKit backend from shinyorg/mauiplatforms (commit 62d4022)
into platforms/MacOS/, refactored to match the conventions of the canonical
Linux.Gtk4 backend.

New projects (all net10.0-macos, SupportedOSPlatformVersion=14.0):
- Microsoft.Maui.Platforms.MacOS — core handlers, hosting, platform services
- Microsoft.Maui.Platforms.MacOS.Essentials — Essentials implementations
- Microsoft.Maui.Platforms.MacOS.BlazorWebView — Blazor Hybrid via WKWebView
- MacOS.Sample — comprehensive ~30-page programmatic sample, with optional
  EnableMauiDevFlow ProjectReference toggle (matches Linux.Gtk4.Sample pattern)

Naming refactor (upstream → maui-labs):
- Package id Platform.Maui.MacOS[.X] → Microsoft.Maui.Platforms.MacOS[.X]
- Namespace Microsoft.Maui.Platform.MacOS[.Sub] → Microsoft.Maui.Platforms.MacOS[.Sub]
- MacOS* class prefix preserved
- UseMauiAppMacOS<TApp>() entry point preserved

Folder reorganization (to match Linux.Gtk4 layout):
- Dispatching/MacOSDispatcher*.cs → Platform/
- Hosting/MacOS{MauiApplication,MauiContext,MauiContextExtensions}.cs → Platform/
- Handlers/MacOSFontNamedSizeService.cs → Platform/
- build/*.targets → buildTransitive/
- ILLink.Descriptors.xml updated to reference the new assembly names

DevFlow integration:
- Microsoft.Maui.DevFlow.Agent and .Blazor switched from PackageReference
  on the shinyorg NuGets to ProjectReference on the local platforms/MacOS
  projects, eliminating the duplicate AddMacOS lifecycle extension.
- BlazorWebViewDebugService.cs and adjacent samples updated to the new
  namespace.
- Removed shinyorg Platform.Maui.MacOS* PackageVersion entries from
  Directory.Packages.props and the corresponding properties from
  eng/Versions.props.

Build validation: dotnet build platforms/MacOS/MacOS.slnx → 0 errors.
DevFlow.Sample.MacOS, Microsoft.Maui.DevFlow.Agent, and Linux.Gtk4.slnx
also continue to build cleanly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Code Review — PR #161 (macOS AppKit Backend)

Reviewed: build config, core platform, handlers, Blazor WebView, Essentials, and DevFlow rewire. The DevFlow rewire (namespace migration, ProjectReference paths, conditional compilation) is clean — no regressions found. All old Microsoft.Maui.Platform.MacOS references are gone; all paths resolve correctly.

Below are the substantive findings from the new platform code, ordered by severity.


🔴 CRITICAL

# File Line(s) Finding
1 platforms/MacOS/src/MacOS.BlazorWebView/BlazorWebViewHandler.cs 358, 366, 375 Silent exception swallowing + null-forgiving dereference in URL scheme handler. GetResponseBytes uses url! and _handler._webviewManager! with null-forgiving operators, but the outer catch at line 358 swallows all exceptions silently. A malformed URL or a disposed _webviewManager produces no error and no response — the WKWebView request hangs. Fix: Add explicit null guards for _webviewManager; return 400/503 status codes instead of swallowing.
2 platforms/MacOS/src/MacOS.BlazorWebView/BlazorWebViewHandler.cs 99–116 Race condition on Blazor WebView disposal. DisconnectHandler fires DisposeAsync() as fire-and-forget (line 110), then sets _webviewManager = null (line 116). Meanwhile MessageReceived (line 124) can still be invoked by WebKit on the old reference. Fix: Set _webviewManager = null before calling DisposeAsync, or use Interlocked.Exchange.
3 platforms/MacOS/src/MacOS/Platform/MacOSModalManager.cs 139, 222, 264, 295 NSWindow resource leak. Modal windows are created with ReleasedWhenClosed = false but never Dispose()d in RemoveSheet or RemoveModalWindow. Repeated open/close of modals leaks native window resources unboundedly. Fix: Call entry.ModalWindow.Dispose() after OrderOut.
4 platforms/MacOS/src/MacOS.Essentials/GeolocationImplementation.cs 29–51 CLLocationManager lifecycle leak. If the TCS is resolved via cancellation (line 42) or delegate failure (line 125), StopUpdatingLocation at line 48 either never runs (exception path) or runs after the manager is already null. There is no finally block ensuring cleanup. Also the manager is not disposed. Fix: Wrap in try/finally; dispose the manager.
5 platforms/MacOS/src/MacOS.Essentials/GeolocationImplementation.cs 19–27 Ephemeral CLLocationManager can be GC'd. GetLastKnownLocationAsync creates a local CLLocationManager that is not stored in a field. The .NET runtime may collect it before manager.Location is accessed. Fix: Store in a field or use GC.KeepAlive(manager) after use.
6 platforms/MacOS/Directory.Packages.props 4 CPM disabled for the entire MacOS subtree. ManagePackageVersionsCentrally is set to false, opting out of Central Package Management. This means the hardcoded Version attributes in the MacOS csproj files bypass the repo's version governance in Directory.Packages.props. Version drift between MacOS platform and DevFlow packages becomes invisible. Fix: Set to true and migrate inline versions to the root CPM file.

🟡 MODERATE

# File Line(s) Finding
7 platforms/MacOS/src/MacOS/Platform/MacOSMauiApplication.cs 75, 82, 89, 96 _windows list iterated without snapshot. AppKit lifecycle callbacks iterate _windows directly. If AddWindow/RemoveWindow is called concurrently (e.g., during multi-window creation), the iteration throws InvalidOperationException. Note that line 103 (WillTerminate) already uses .ToArray() — the other four callbacks do not. Fix: Use _windows.ToArray() in all lifecycle callbacks, same as WillTerminate.
8 platforms/MacOS/src/MacOS/Platform/MacOSMauiApplication.cs 126 Inline new NSWindow() has no strong reference. The window object is only held by the IMauiContext scope. If the scope uses a weak reference internally, the window can be collected. Fix: Store in a local variable before passing to MakeWindowScope.
9 platforms/MacOS/src/MacOS.Essentials/SecureStorageImplementation.cs 78–79 Keychain errors throw bare Exception. SetValue throws new Exception(...) on failure. This makes it impossible for callers to catch keychain-specific errors (e.g., SecStatusCode.DuplicateItem after a failed Remove). Fix: Throw a typed exception or InvalidOperationException with the SecStatusCode.
10 platforms/MacOS/src/MacOS.Essentials/GeolocationImplementation.cs 69–72 StopListeningForeground doesn't dispose the manager or clear the delegate. The CLLocationManager and its ContinuousLocationDelegate (which captures this) are abandoned but not disposed, creating a reference cycle that delays GC. Fix: Set Delegate = null, call Dispose(), then null the field.
11 platforms/MacOS/src/MacOS.Essentials/BatteryImplementation.cs ~69–89 IOKit CFRunLoopSource leak on exception. If CFRunLoopAddSource throws after IOPSNotificationCreateRunLoopSource succeeds, the source handle is never released. Fix: Add try/catch cleanup for the native handle.
12 platforms/MacOS/src/MacOS/Platform/MacOSModalManager.cs 155, 160 Silent no-op when no presenting window. GetTopmostModalHost() can return null, and presentingWindow?.BeginSheet(...) silently does nothing. The modal entry is still added to _modalStack even though the sheet was never shown. Fix: Guard with a null check and throw or return early before adding to the stack.
13 platforms/MacOS/Directory.Build.props 8 (see full file) Hardcoded MauiVersion will drift. The MacOS subtree pins MauiVersion to 10.0.41 independently of eng/Versions.props. When the repo bumps MAUI, this subtree will silently stay on the old version. Fix: Import eng/Versions.props and derive MauiVersion from MicrosoftMauiControlsVersion.

🟢 MINOR

# File Line(s) Finding
14 platforms/MacOS/src/MacOS.BlazorWebView/BlazorWebViewHandler.cs 381 headers["Content-Type"] can throw KeyNotFoundException. If the response headers don't contain Content-Type, this line throws inside the silent catch block, returning an empty 404 instead of the actual content. Fix: Use headers.TryGetValue.
15 platforms/MacOS/src/MacOS.Essentials/SecureStorageImplementation.cs 60 match.ValueData may be null. If the keychain record exists but has no value data, NSString.FromData(null, ...) will throw. Fix: Null-check match?.ValueData before conversion.
16 platforms/MacOS/src/MacOS.Essentials/GeolocationImplementation.cs 106 Typo: AccurracyBestForNavigation (triple-r). This compiles because it's a real .NET MAUI API name, but worth noting for readability.

Summary: The DevFlow rewire is regression-free. The new platform code has several resource-lifecycle and thread-safety issues typical of a first port — the most urgent are the Blazor WebView disposal race (#2), modal window leaks (#3), and CLLocationManager lifecycle bugs (#4–5). The CPM opt-out (#6) should be resolved before merge to maintain version governance.

Generated by Expert Code Review (auto) for issue #161 · ● 17.9M

Comment thread platforms/MacOS/src/MacOS.BlazorWebView/BlazorWebViewHandler.cs Outdated
Comment thread platforms/MacOS/src/MacOS/Handlers/ShellHandler.cs Outdated
Comment thread platforms/MacOS/src/MacOS.Essentials/GeolocationImplementation.cs Outdated
Comment thread platforms/MacOS/src/MacOS.Essentials/GeolocationImplementation.cs Outdated
Comment thread platforms/MacOS/src/MacOS.BlazorWebView/BlazorWebViewHandler.cs
Comment thread platforms/MacOS/src/MacOS/Platform/MacOSModalManager.cs
Redth and others added 9 commits April 27, 2026 15:20
Use atomic manager exchange during disconnect and volatile reads for URL scheme requests so in-flight WebKit callbacks return a service-unavailable response instead of dereferencing a cleared manager.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Correct the CoreLocation AccuracyBestForNavigation binding name used for best geolocation accuracy.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Use a per-call CLLocationManager for one-shot geolocation requests and ensure updates are stopped in a finally block so cleanup runs after success, failure, or cancellation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Canonicalize direct bundle resource paths before serving files so traversal segments cannot escape the BlazorWebView content root.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Store deferred window KVO observers and remove them when the handler disconnects to avoid observers surviving WKWebView teardown.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Dispose modal NSWindows after they are removed so sheets and window modals do not retain their view hierarchies.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Keep the experimental AppKit backend diagnostics as warnings under Arcade CI's warnaserror build so DevFlow macOS packaging can complete.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Apply the remaining non-inline review feedback from PR #161:
- Move the MacOS subtree back onto repo-level Central Package Management.
- Derive MauiVersion from eng/Versions.props instead of hardcoding it.
- Snapshot application window lifecycle iteration and keep the created NSWindow in a local before scoping it.
- Guard modal sheet presentation when no host window exists.
- Tighten geolocation manager disposal for one-shot and continuous updates.
- Harden secure storage null/error handling.
- Release the battery run loop source if listener startup fails.
- Avoid URL scheme handler exceptions for malformed URLs or missing Content-Type.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Redth
Copy link
Copy Markdown
Member Author

Redth commented Apr 27, 2026

Addressed the remaining non-inline review summary findings in d4f6b83.

This covers the MacOS subtree CPM/version-governance feedback, lifecycle snapshotting in MacOSMauiApplication, geolocation and battery cleanup paths, secure storage error/null handling, modal sheet host guarding, and the Blazor URL scheme malformed URL / missing Content-Type cases. The inline review threads were already resolved, and I confirmed there are no unresolved review threads remaining.

Raise the centrally managed Microsoft.AspNetCore.Components.WebView version to satisfy the Linux.Gtk4 transitive dependency after moving the MacOS subtree onto repo-level Central Package Management.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@Redth Redth marked this pull request as ready for review April 28, 2026 17:00
Copilot AI review requested due to automatic review settings April 28, 2026 17:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot wasn't able to review this pull request because it exceeds the maximum number of lines (20,000). Try reducing the number of changed lines and requesting a review from Copilot again.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Code Review — PR #161 (macOS AppKit Backend)

Methodology: 3 independent reviewers with adversarial consensus. Findings are included only when 2+ reviewers agree (disputed findings received targeted follow-up evaluation). The DevFlow rewire (namespace migration, ProjectReference paths, conditional compilation) is clean — no regressions found.


🔴 CRITICAL

# Consensus File Finding
1 3/3 BlazorWebViewHandler.cs:386–388 URL scheme tasks left incomplete on error/empty URL. The catch block swallows exceptions without calling DidFailWithError(), violating WKURLSchemeHandler contract. WKWebView hangs permanently on failed requests.
2 2/3 BlazorWebViewHandler.cs:103 WKUserContentController retain cycle. AddScriptMessageHandler in CreatePlatformView is never paired with RemoveScriptMessageHandler in DisconnectHandler. Strong reference from UserContentController prevents deallocation of the entire web view stack.

🟡 MODERATE

# Consensus File Finding
3 3/3 BatteryImplementation.cs:154–156 BatteryState.Full never returned. Boolean logic for fullyCharged is inverted — the &&/`
4 3/3 1 MacOSMauiAssetFileProvider.cs:29–30 Path traversal in NSBundle lookup. First code path lacks IsPathUnderRoot boundary check (unlike the fallback path at line 42–44). ../ in subpath can access files outside the intended asset root.
5 3/3 1 MacOSModalManager.cs:163–165 Sheet native close desynchronizes MAUI modal stack. Sheets are created with NSWindowStyle.Closable but the BeginSheet completion handler is a no-op. Native close leaves stale entries in _modalStack, corrupting subsequent push/pop operations.
6 2/3 MacOSMauiApplication.cs:126–127 Dummy NSWindow placeholder in MakeWindowScope. A bare new NSWindow() is stored as a WeakReference in the context. GC collects it; the real window (created later by WindowHandler) is never registered back.
7 2/3 ConnectivityImplementation.cs:87 async void exception handler can crash the process. Unhandled exceptions in OnReachabilityChanged after await Task.Delay(100) propagate to the unhandled-exception handler and terminate the app.

1 Initially flagged by 1/3, confirmed by targeted follow-up with the other 2 reviewers.

Discarded (single reviewer only, not confirmed)

  • MauiApp/DI container not disposed on termination (1/3 — other 2 reviewers noted process teardown reclaims resources)
  • DeviceDisplayImplementation CFString leak, GeolocationImplementation/BatteryImplementation missing IDisposable, fire-and-forget AddRootComponentAsync, ScreenshotImplementation NSImage leak, MacOSBlazorDispatcher async void lambda, WindowHandler static field thread safety, MauiIcons.Cupertino hardcoded version, ConnectivityImplementation hardcoded probe host, Directory.Packages.props version coupling (single-reviewer only — discarded per budget cap)

CI & Test Coverage

  • No CI pipeline currently covers platforms/MacOS/ (standalone MacOS.slnx, not in root MauiLabs.slnx)
  • No unit tests added for the new platform code
  • PR author validated dotnet build for all 5 project configurations — 0 errors reported

Summary

The 2 critical findings (both in BlazorWebViewHandler.cs) should be addressed before merge — they can cause WKWebView hangs and unbounded memory leaks in Blazor Hybrid apps. The 5 moderate findings represent real bugs (battery state logic, modal state corruption, potential path traversal) that are worth fixing but individually not blocking for a preview-quality experimental backend.

Generated by Expert Code Review (auto) for issue #161 · ● 24.3M

Comment on lines +154 to +156
if ((!TryGet<NSNumber>(dic, kIOPSIsChargedKey, out var charged) || charged?.BoolValue != true) ||
(!TryGet<NSNumber>(dic, kIOPSIsFinishingChargeKey, out var finishing) && finishing?.BoolValue != true))
fullyCharged = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — BatteryState.Full is never returned — boolean logic inverted (3/3 reviewers)

Tracing for a typical fully-charged MacBook (IsCharged = true, IsFinishingCharge key absent from IOKit dict):

  • First sub-expression: !true || (true != true)false
  • Second sub-expression: TryGet returns false (key absent) → !false = true; finishing = nullnull != true = true; true && truetrue
  • false || truetruefullyCharged = falsewrong

The && in the second operand means "key absent AND value not true" always fires when the key is missing (the common macOS case). BatteryState.Full will never be returned.

Suggested fix:

var isCharged = TryGet<NSNumber>(dic, kIOPSIsChargedKey, out var charged) 
    && charged?.BoolValue == true;
var isFinishing = TryGet<NSNumber>(dic, kIOPSIsFinishingChargeKey, out var finishing) 
    && finishing?.BoolValue == true;
if (!isCharged && !isFinishing)
    fullyCharged = false;

Comment on lines +163 to +165
_modalStack.Add(entry);

presentingWindow.BeginSheet(sheetWindow, (returnCode) => { });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — Sheet native close desynchronizes MAUI modal stack (3/3 reviewers after dispute)

Sheet windows are created with NSWindowStyle.Closable (line 136), giving users a native close button. But the BeginSheet completion handler here is a no-op. When a user clicks the native close button:

  1. AppKit dismisses the sheet and fires this completion handler (ignored)
  2. _modalStack retains the stale entry → ModalCount/HasModals return wrong values
  3. A subsequent PopModal() calls EndSheet on an already-dismissed window (undefined behavior)

Note: The Window-modal style (line 269) already avoids this by omitting Closable — the asymmetry on sheets appears accidental.

Suggested fix: Either handle the BeginSheet completion callback to sync the MAUI stack (fire ModalPopped, remove the entry), or remove NSWindowStyle.Closable from sheet windows to match the Window-modal pattern.

Comment thread platforms/MacOS/src/MacOS.BlazorWebView/BlazorWebViewHandler.cs Outdated
Comment thread platforms/MacOS/src/MacOS.BlazorWebView/BlazorWebViewHandler.cs
Comment on lines +126 to +127
var platformWindow = new NSWindow();
var windowContext = applicationContext.MakeWindowScope(platformWindow);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — Dummy NSWindow placeholder causes context/window mismatch (2/3 reviewers)

MakeWindowScope stores this bare new NSWindow() as a WeakReference<NSWindow>. Since no strong reference is retained, GC can collect the dummy window. Meanwhile, WindowHandler.CreatePlatformElement() creates the real NSWindow later — but that real window is never registered back into the context. Any service resolving NSWindow from IMauiContext.Services after the first GC cycle gets null.

This is inconsistent with the Linux.Gtk4 backend, which passes the actual platform window to MakeWindowScope only after it's fully constructed.

Suggested fix: Delay context creation until after WindowHandler.CreatePlatformElement() has run, or have WindowHandler.ConnectHandler replace the placeholder in the context with PlatformView.

Comment on lines +29 to +30
subpath = subpath.TrimStart('/');
var resourcePath = Path.Combine(_contentRoot, subpath);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — Path traversal: NSBundle lookup lacks boundary check (3/3 reviewers after dispute)

The first code path constructs resourcePath = Path.Combine(_contentRoot, subpath) where subpath can contain ../ sequences, then passes the result to NSBundle.PathForResource without any boundary validation. The second code path (line 42–44) correctly uses Path.GetFullPath + IsPathUnderRoot, but the NSBundle path does not.

A crafted ../../Contents/Info.plist could expose bundle files outside the intended asset root (e.g., the app binary, Info.plist, embedded resources).

Suggested fix — add canonicalization before the NSBundle lookup:

subpath = subpath.TrimStart('/');
if (subpath.Contains(".."))
    return new NotFoundFileInfo(subpath);

_remoteHostReachability = null;
}

async void OnReachabilityChanged(NetworkReachabilityFlags flags)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE — async void can crash the process on unhandled exceptions (2/3 reviewers)

async void OnReachabilityChanged means any exception after the await Task.Delay(100) propagates to the thread pool's unhandled-exception handler, which terminates the process. If a ConnectivityChanged subscriber throws (e.g., accesses a disposed object or updates UI on the wrong thread), the app crashes instead of logging the error.

Suggested fix:

async void OnReachabilityChanged(NetworkReachabilityFlags flags)
{
    try
    {
        await Task.Delay(100).ConfigureAwait(false);
        // ... existing logic
    }
    catch (Exception ex)
    {
        System.Diagnostics.Debug.WriteLine(
            $"Connectivity change handler error: {ex}");
    }
}

@github-actions

This comment has been minimized.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Code Review: 26 findings posted inline (5 moderate, 21 minor). See summary comment for full details.

Generated by Expert Code Review · ● 27.5M

{
var url = urlSchemeTask.Request.Url?.AbsoluteString;
if (string.IsNullOrEmpty(url))
return;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE · low confidence — single reviewer (batch split)

URL scheme task abandoned on empty URL. Per Apple's WKURLSchemeHandler contract, every StartUrlSchemeTask must eventually call either DidFinish() or DidFailWithError(). This early return does neither — WKWebView will wait indefinitely for the response, hanging the resource load.

Fix:

if (string.IsNullOrEmpty(url))
{
    urlSchemeTask.DidFailWithError(new NSError(NSError.NSUrlErrorDomain, -1));
    return;
}

Comment on lines +30 to +32
pStr = Marshal.AllocHGlobal(pLen);
NativeMethods.sysctlbyname("hw.model", pStr, ref pLen, IntPtr.Zero, 0);
return Marshal.PtrToStringAnsi(pStr) ?? string.Empty;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MODERATE · low confidence — single reviewer (batch split)

Native memory leak. Marshal.AllocHGlobal(pLen) allocates unmanaged memory that is never freed. Every DeviceInfo.Model read leaks. Additionally, if sysctlbyname throws between the alloc and return, the memory is also leaked.

Fix: Wrap in try/finally with Marshal.FreeHGlobal(pStr).

_currentState = state;
_currentSource = source;
var args = new BatteryInfoChangedEventArgs(level, state, source);
DispatchQueue.MainQueue.DispatchAsync(() => _batteryInfoChanged?.Invoke(null, args));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR · low confidence — single reviewer (batch split)

Events fired with null sender. Invoke(null, args) here (and in ConnectivityImplementation, DeviceDisplayImplementation) departs from .NET convention and MAUI's iOS implementation which passes this. Subscribers casting sender to IBattery will get NullReferenceException.

Fix: Pass this as the sender.

protected override void DisconnectHandler(NSScrollView platformView)
{
if (VirtualView != null)
VirtualView.ModelChanged += OnModelChanged;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR · low confidence — single reviewer (batch split)

DisconnectHandler subscribes the event instead of unsubscribing. Uses += instead of -=, doubling the subscription on every connect/disconnect cycle. Causes duplicate OnModelChanged invocations and a memory leak.

Fix: Change to VirtualView.ModelChanged -= OnModelChanged;.

PlatformView.SetTabs(titles);

if (TabbedPage.Children.Count > 0)
SelectPage(0);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR · low confidence — single reviewer (batch split)

SetupTabs() always selects page 0. If TabbedPage.CurrentPage is set to a non-first tab, or the user has selected a tab, then the pages collection changes — selection is forcibly reset to the first tab.

Fix: Preserve current selection: use TabbedPage.Children.IndexOf(TabbedPage.CurrentPage) when valid, fallback to 0.

{
try
{
webviewManager.DisposeAsync().AsTask().ContinueWith(_ => { });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR · low confidence — single reviewer (batch split)

DisposeAsync fire-and-forget with empty ContinueWith. Any disposal exception (e.g., closing connections, flushing buffers) is silently swallowed. Consider at minimum logging errors in the continuation.

/// Transparent overlay that captures mouse events in the titlebar zone
/// and initiates window drag. All other events pass through to the WKWebView.
/// </summary>
sealed class TitlebarDragOverlayView : NSView
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR · low confidence — single reviewer (batch split)

Missing IsFlipped override on TitlebarDragOverlayView. AppKit uses bottom-left origin by default. The HitTest coordinate comparisons in this class may use incorrect Y values relative to the themeFrame's coordinate system, causing unreliable titlebar drag detection.

Fix: Add public override bool IsFlipped => true; or verify the coordinate assumptions match the parent's coordinate system.

cmdMapper[nameof(IView.Focus)] = MapFocus;
}
}
catch
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR · low confidence — single reviewer (batch split)

Static constructor silently swallows all mapper registration failures. If the cast or registration fails (MAUI type change), 20+ property mappers (Shadow, Opacity, Visibility, Background, Transform, etc.) silently stop working for every control.

Fix: Add Debug.Assert(false, ...) or Trace.TraceError inside the catch so failures surface in dev builds.

Comment on lines +11 to +14
public bool ContainsKey(string key, string? sharedName = null)
{
lock (locker)
return GetUserDefaults(sharedName)[key] != null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR · low confidence — single reviewer (batch split)

NSUserDefaults instance leak in ContainsKey. When sharedName is non-null, GetUserDefaults allocates a new NSUserDefaults that is never disposed. Other methods (Set, Get, Remove, Clear) correctly use using var.

Fix: using var userDefaults = GetUserDefaults(sharedName);

Comment on lines +26 to +27
<EmbeddedResource Include="ILLink.Descriptors.xml">
<LogicalName>ILLink.Descriptors.xml</LogicalName>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 MINOR · low confidence — single reviewer (batch split)

ILLink.Descriptors.xml registered as EmbeddedResource — trimmer won't consume it. The .NET trimmer looks for TrimmerRootDescriptor items, not embedded resources. In a release trimmed/AOT build, the API types listed in this XML won't be preserved.

Fix: Register as a trimmer descriptor:

<ItemGroup>
  <TrimmerRootDescriptor Include="ILLink.Descriptors.xml" />
</ItemGroup>

@github-actions
Copy link
Copy Markdown
Contributor

Expert Code Review — PR #161

Methodology: 3 independent reviewers with adversarial consensus. Due to the large diff (190+ changed files, 29K+ additions), files were split into 3 batches — each reviewer analyzed a different batch. All findings are annotated "low confidence — single reviewer (batch split)" with severity downgraded by one level per the batch-split protocol.

23 findings posted as inline comments (2 moderate, 21 minor).

Batch Assignments

  • Reviewer 1: Modified existing files + new Essentials + BlazorWebView (73 files)
  • Reviewer 2: New Handlers + Platform + Hosting + Lifecycle (80 files)
  • Reviewer 3: Build config + Docs + Sample app + Plugins (61 files)

Finding Summary

Severity Count Key Areas
🟡 Moderate 2 EntryHandler reflection no-op (stale PlatformView), GestureManager.FindMauiView stub (pinch NRE)
🟢 Minor 21 Battery logic bug, native memory leaks (DeviceInfo, DeviceDisplay), CancellationToken removal, exception swallowing in BlazorWebView, modal stack corruption, observer/subscription leaks, docs/skill inaccuracies

CI Status

All 6 check runs passed ✅ (license/cla, build on ubuntu-24.04, macOS, and windows × 2 workflows).

Test Coverage

  • No unit tests added for the new platforms/MacOS/ code
  • No CI pipeline currently covers platforms/MacOS/ (standalone MacOS.slnx, not in root MauiLabs.slnx)
  • PR author validated dotnet build for 5 project configurations — 0 errors reported

Notes

  • The DevFlow rewire (namespace migration from Microsoft.Maui.Platform.MacOSMicrosoft.Maui.Platforms.MacOS, PackageReferenceProjectReference switch) is clean — no regressions detected across all 3 reviewers
  • The two moderate findings (EntryHandler reflection, GestureManager stub) represent functional regressions that would affect IsPassword toggling and all gesture recognizers respectively — worth addressing before merge
  • The most common pattern across minor findings is native resource leaks (unfreed handles, unremoved observers, unstopped managers) — typical for a first port and suitable for follow-up issues

Generated by Expert Code Review · 3 independent reviewers with adversarial consensus

Generated by Expert Code Review · ● 41.7M ·

jfversluis and others added 6 commits May 2, 2026 16:33
Merge conflicts:
- Directory.Packages.props: keep both WebView and Maps entries
- eng/Versions.props: keep both WebView and Maps version properties

Convention fixes:
- Set PackRepoRootReadme=false in Directory.Build.props
- Fix PackagePath to '/' in all 3 csprojs
- Add centralized versions to eng/Versions.props (0.3.0)
- Add Version=$(PlatformMauiMacOS*Version) to all csprojs
- Remove leftover LICENSE from upstream import
- Add macOS AppKit section to repo-root README
- Add macOS projects to MauiLabs.slnx
- Add ci-macos-appkit.yml CI workflow
- Add MacOS build job and publish stage to devflow-official.yml

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Hardcoded version in Directory.Packages.props moved to centralized
version management. Package is MIT-licensed, sample-only (not shipped),
no signing or third-party license entry needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Stub sensor events (CS0067) and reflection-based field access (IL2070)
are pre-existing in the imported code. Add to WarningsNotAsErrors to
unblock CI, consistent with the existing 13 suppressed warnings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
.NET 10 macOS workload requires Xcode 26.3. Pass xcode-version to
_build.yml, matching the pattern used by ci-essentialsai.yml.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ConvertFromString returns  use nullable cast to Geometry?.object?

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1. WKWebView hang: StartUrlSchemeTask now calls DidFailWithError on
   empty URLs and in catch blocks, preventing WebView hangs from
   incomplete URL scheme tasks.

2. Retain cycle: DisconnectHandler now calls RemoveScriptMessageHandler
   to break the strong reference from WKUserContentController to the
   WebViewScriptMessageHandler, preventing memory leaks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jfversluis jfversluis merged commit 649090a into main May 2, 2026
11 checks passed
@jfversluis jfversluis deleted the redth/macos-appkit-backend branch May 2, 2026 16:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants