Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ MauiSherpa is a .NET 10 MAUI Blazor Hybrid desktop application for managing deve
- .NET SDK management via `dotnetup` (install/update SDKs & runtimes — see `docs/dotnet-sdk-management.md`)
- GitHub Copilot integration

**Platforms:** Mac Catalyst, Windows
**Platforms:** macOS (AppKit), Windows, Linux (GTK)
**Bundle Identifier:** `codes.redth.mauisherpa`

## Project Structure
Expand All @@ -24,12 +24,14 @@ MAUI.Sherpa/
│ │ ├── Services/ # Service implementations
│ │ ├── ViewModels/ # MVVM ViewModels
│ │ └── Interfaces.cs # All interface definitions
│ ├── MauiSherpa/ # MAUI app with Blazor UI
│ ├── MauiSherpa/ # Shared MAUI app with Blazor UI (Windows head)
│ │ ├── Components/ # Reusable Blazor components
│ │ ├── Pages/ # Blazor page components
│ │ ├── Services/ # Platform-specific service implementations
│ │ ├── Platforms/ # Platform-specific code (MacCatalyst, Windows)
│ │ ├── Platforms/ # Platform-specific code (Windows)
│ │ └── wwwroot/ # Static assets (CSS, JS, index.html)
│ ├── MauiSherpa.MacOS/ # macOS AppKit app head (net10.0-macos)
│ ├── MauiSherpa.LinuxGtk/ # Linux GTK app head
│ └── MauiSherpa.Workloads/ # .NET SDK workload querying library
│ ├── Models/ # Workload data models
│ ├── Services/ # Workload services
Expand All @@ -43,8 +45,8 @@ MAUI.Sherpa/
## Build Commands

```bash
# Build for Mac Catalyst
dotnet build src/MauiSherpa -f net10.0-maccatalyst
# Build for macOS (AppKit head)
dotnet build src/MauiSherpa.MacOS -f net10.0-macos

# Build for Windows (on Windows only)
dotnet build src/MauiSherpa -f net10.0-windows10.0.19041.0
Expand All @@ -55,23 +57,26 @@ dotnet build MauiSherpa.sln
# Run all tests
dotnet test MauiSherpa.sln

# Publish Mac Catalyst app
dotnet publish src/MauiSherpa -f net10.0-maccatalyst -c Release
# Publish macOS app
dotnet publish src/MauiSherpa.MacOS -f net10.0-macos -c Release

# Publish Windows app
dotnet publish src/MauiSherpa -f net10.0-windows10.0.19041.0 -c Release
```

`src/MauiSherpa` is the shared UI project and the Windows head. It only builds on Windows — on
macOS and Linux its `Build`/`Rebuild`/`Publish` targets are no-ops so solution builds still work.

### Launching the App

**IMPORTANT:** `dotnet run` does NOT work for .NET MAUI apps (until .NET 11). Use one of:
```bash
# Option 1: Build with -t:Run target (keeps process alive until app exits)
dotnet build src/MauiSherpa -f net10.0-maccatalyst -t:Run
dotnet build src/MauiSherpa.MacOS -f net10.0-macos -t:Run

# Option 2: Build then manually open the .app bundle
dotnet build src/MauiSherpa -f net10.0-maccatalyst
open "src/MauiSherpa/bin/Debug/net10.0-maccatalyst/maccatalyst-arm64/MAUI Sherpa.app"
dotnet build src/MauiSherpa.MacOS -f net10.0-macos
open "src/MauiSherpa.MacOS/bin/Debug/net10.0-macos/osx-arm64/MAUI Sherpa.app"
```

**Always launch from `bin/` path**, NOT `artifacts/`. The `artifacts/` copy may be stale and missing DLLs.
Expand Down Expand Up @@ -169,7 +174,7 @@ All modals use `modalInterop.js` (`wwwroot/js/modalInterop.js`) for focus trappi
- Escape closes the modal
- Auto-focuses `.btn-primary:not([disabled])` on open

**CRITICAL:** In Blazor WebView (Mac Catalyst), browser default Tab navigation does NOT work. All Tab keypresses must be intercepted with `preventDefault()` and explicit `.focus()` calls via JS interop.
**CRITICAL:** In Blazor WebView (macOS), browser default Tab navigation does NOT work. All Tab keypresses must be intercepted with `preventDefault()` and explicit `.focus()` calls via JS interop.

### Text Selection Prevention
Global `user-select: none` is applied on `*` to prevent accidental text selection in the hybrid app. Selectively re-enabled on: `input`, `textarea`, `select`, `code`, `pre`, `.mono`, `.terminal-output`, `.log-entry`, `.error-message`, `.chat-message`, and `.text-selectable`.
Expand Down Expand Up @@ -205,17 +210,15 @@ When making or reviewing UI changes, always verify:

## Platform-Specific Notes

### Mac Catalyst
### macOS

**App data path:** Use `AppDataPath.GetAppDataDirectory()` which returns `~/Library/Application Support/MauiSherpa/`. Do NOT use `SpecialFolder.ApplicationData` (resolves to `~/Documents/.config/` which is TCC-protected).

**Secure storage in Debug:** Ad-hoc Debug builds have different code signatures each rebuild, making macOS Keychain entries inaccessible. Debug builds always use fallback file storage (`#if DEBUG _usesFallback = true;` in `SecureStorageService`).

**File save dialogs:** `PickSaveFileAsync` (native `NSSavePanel`) creates an empty file at the chosen path. Tools like `keytool` that refuse to overwrite existing files need the empty file deleted first.

**Mac Catalyst delegate:** Use `DidPickDocumentAtUrls` (plural), NOT `DidPickDocument` (singular). The singular form doesn't fire on modern Mac Catalyst.

**Hardened runtime:** MSBuild `EnableHardenedRuntime=true` does NOT work for .NET MAUI Mac Catalyst. Must re-sign with `codesign --force --options runtime --timestamp` after publish.
**Duplicate native libraries:** The app heads reference both `MauiSherpa.AppInspector` and `MauiSherpa.Core`, and AppInspector also references Core, so `GitHub.Copilot.SDK` stages `libcopilot_runtime.dylib` several times over. The Apple SDK's `InstallNameTool` task runs its items in parallel against a shared `.tmp` path and crashes on duplicates, so `build/DedupeNativeReferences.targets` collapses each destination to one entry. Import it from any new Apple app head.

**Logging:** Logs saved to `~/Library/Application Support/MauiSherpa/logs/maui-sherpa-{yyyy-MM-dd}.log`.

Expand Down
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,7 @@ dotnet restore
# Build for macOS (AppKit)
dotnet build src/MauiSherpa.MacOS -f net10.0-macos

# Build for Mac Catalyst
dotnet build src/MauiSherpa -f net10.0-maccatalyst

# Build for Windows
# Build for Windows (Windows only)
dotnet build src/MauiSherpa -f net10.0-windows10.0.19041.0

# Run tests
Expand All @@ -299,11 +296,11 @@ dotnet test
```
MAUI.Sherpa/
├── src/
│ ├── MauiSherpa/ # Main MAUI Blazor Hybrid app
│ ├── MauiSherpa/ # Shared MAUI Blazor Hybrid UI + Windows app head
│ │ ├── Components/ # Reusable Blazor components
│ │ ├── Pages/ # Blazor page components
│ │ ├── Services/ # Platform-specific services
│ │ └── Platforms/ # Platform code (MacCatalyst, Windows)
│ │ └── Platforms/ # Platform code (Windows)
│ ├── MauiSherpa.MacOS/ # macOS AppKit app head
│ ├── MauiSherpa.LinuxGtk/ # Linux GTK4 app head
│ ├── MauiSherpa.Core/ # Business logic library
Expand Down
56 changes: 56 additions & 0 deletions build/DedupeNativeReferences.targets
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<Project>

<!--
App heads reference both MauiSherpa.AppInspector and MauiSherpa.Core, and AppInspector itself
references Core. GitHub.Copilot.SDK's transitive targets stage libcopilot_runtime.dylib for
every project that references it, so the head resolves the same native library several times
over: byte-identical content, identical RelativePath, but differing source paths and project
bookkeeping metadata.

The Apple SDK's InstallNameTool task processes items in parallel (Task.WaitAll) and derives its
scratch file from the destination, so entries sharing a ReidentifiedPath race on the same
"<name>.tmp" and the build dies with:

install_name_tool: cannot rename .../libcopilot_runtime.dylib.tmp to ....tmp.XXXXXX
The "InstallNameTool" task failed unexpectedly.

Collapse each destination down to a single native reference before the SDK computes what to
re-identify. Batched per RelativePath; MatchOnMetadata scopes the removal to that destination so
a source file legitimately published to more than one location is left alone.
-->
<Target Name="_RemoveDuplicateNativeReferences"
BeforeTargets="_ComputeDynamicLibrariesToReidentify"
Outputs="%(_FileNativeReference.RelativePath)"
Condition="'@(_FileNativeReference)' != ''">

<ItemGroup>
<_NativeReferencesForPath Include="@(_FileNativeReference)" />
</ItemGroup>

<PropertyGroup>
<_NativeReferencesForPathCount>@(_NativeReferencesForPath->Count())</_NativeReferencesForPathCount>
<_NativeReferenceToKeep />
</PropertyGroup>

<!-- Task batching leaves the last item's identity behind, which is all we need: any one of the
duplicates will do, we just have to pick the same one every time. -->
<PropertyGroup Condition="$(_NativeReferencesForPathCount) &gt; 1">
<_NativeReferenceToKeep>%(_NativeReferencesForPath.Identity)</_NativeReferenceToKeep>
</PropertyGroup>

<Message Importance="normal"
Condition="$(_NativeReferencesForPathCount) &gt; 1"
Text="Collapsing $(_NativeReferencesForPathCount) native references for '%(_FileNativeReference.RelativePath)' to '$(_NativeReferenceToKeep)'." />

<ItemGroup Condition="$(_NativeReferencesForPathCount) &gt; 1">
<_FileNativeReference Remove="@(_NativeReferencesForPath)" MatchOnMetadata="RelativePath" />
<_FileNativeReference Include="@(_NativeReferencesForPath)"
Condition="'%(Identity)' == '$(_NativeReferenceToKeep)'" />
</ItemGroup>

<ItemGroup>
<_NativeReferencesForPath Remove="@(_NativeReferencesForPath)" />
</ItemGroup>
</Target>

</Project>
61 changes: 50 additions & 11 deletions docs/dotnet-sdk-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,47 @@ https://aka.ms/dotnet/dotnetup/{quality}/dotnetup-{rid}[.exe] (+ ".sha512")
- Installed into `~/.dotnetup` (matches the official installer, so an existing install is reused).

### Doctor integration
- The `.NET SDK` "Update available" check is now **fixable**. Its `FixAction` is
`dotnetup-update-sdk:<version>`; applying it bootstraps `dotnetup` if missing, then runs
`sdk install <version> --set-default-install` (Terminal Mode).

#### Which install root wins

Doctor and the SDK Manager must agree on *one* .NET install root, otherwise they report different
active SDKs, feature bands, and workload sets. `DotnetSdkSourceResolver`
(`MauiSherpa.Workloads/Services`) is the single decision point. Precedence:

1. A repo-local `.dotnet/sdk` next to the scanned project directory.
2. The **dotnetup-managed root**, whenever dotnetup manages at least one valid SDK. When several
managed roots exist, it prefers the one matching the process architecture, then the one with the
newest SDK, then ordinal install-root order.
3. The machine-discovered root (`/etc/dotnet/install_location*`, `DOTNET_ROOT`, `PATH`, defaults).

When the managed root wins it is the **sole** source of truth: installed SDK list, active SDK,
feature band, manifests, workload set, and the `dotnet` executable Doctor invokes all come from it,
and system SDKs are ignored. `DoctorService` pins a `LocalSdkService` to that root
(`GetSdkServiceForRoot`) so manifest and workload-set probing reads the same place.

This matters because the GUI Doctor does not inherit shell `PATH`/`DOTNET_ROOT`, and on macOS the
managed root defaults to `~/Library/Application Support/dotnet` — which the machine scan never finds.
`DoctorContext.UsesDotnetUpManagedSdk` reports the outcome, and the Doctor page shows a `dotnetup`
badge on the ".NET SDK Path" row.

#### Prerelease-aware ordering

`SdkVersion` implements `IComparable<SdkVersion>` over a full `NuGetVersion`, and every consumer
sorts through `SdkVersion.SortDescending`. Without this, `11.0.100-preview.4`, `-preview.5`,
`-preview.6` and stable `11.0.100` all compare equal on `Major`/`Minor`/`Patch` and directory or feed
enumeration order silently picks the "active" SDK — which then produces a feature band that matches
no dotnetup workload target and downgrades Doctor to a NuGet-only workload path.

#### Checks and fixes

- The `.NET SDK` "Update available" check is **fixable**. When the managed root is in use, Doctor
reuses dotnetup's own tracked-channel update preview — the same data the SDK Manager shows — and
emits `FixAction: dotnetup-update-sdk:<channel>` (e.g. `11.0.1xx`). Specific channels are preferred
over moving aliases (`latest`, `lts`, `sts`, `preview`), and pinned specs are skipped. Otherwise it
falls back to `dotnetup-update-sdk:<version>`. Applying the fix bootstraps `dotnetup` if missing,
then runs `sdk install <channel|version> --set-default-install` (Terminal Mode).
- A **dotnetup presence** check (Info) shows the installed version, or offers an
`install-dotnetup` fix when it is missing.
- Doctor reconciles **dotnetup-managed SDKs** into the installed-SDK set
(`MergeManagedSdks`) so an applied update actually clears the warning. This matters because
the GUI Doctor does not read shell `PATH`, and on macOS the managed root defaults to
`~/Library/Application Support/dotnet` — which `LocalSdkService` does not otherwise scan.

### SDK Manager page (`/dotnet-sdk`)

Expand Down Expand Up @@ -185,18 +217,25 @@ participate in resolution.
- `Services/WorkloadInstallationStateResolver.cs` — overlays direct SDK installation records on
the active transitive workload graph to classify explicit, included, and available IDs without
inferring state from shared pack folders.
- `Services/DotnetSdkSourceResolver.cs` — pure resolution of the authoritative install root and
SDK set from a local machine scan plus a `DotnetUpListResult`.
- `Services/GlobalJsonWorkloadPinEditor.cs` — JSONC-preserving atomic
`sdk.workloadVersion` edits.
- **`MauiSherpa.Core`** — `IDotnetUpService` (`Interfaces.cs`) + `DotnetUpService`: resolves the
exe, bootstraps (`EnsureInstalledAsync`), queries (`GetToolInfoAsync`, `GetListAsync`), and
builds `ProcessRequest`s for install/update/uninstall. `IDotnetWorkloadService` discovers
feature-band targets, orchestrates inventory queries, builds workload process requests, and
invalidates per-root caches after writes. Both app heads register these services.
- **Doctor** consumes the same `IDotnetWorkloadService` target, availability result, environment,
command builder, and refresh path as the SDK Manager so the two surfaces cannot disagree.
- **Doctor** resolves its install root through `DotnetSdkSourceResolver`, then consumes the same
`IDotnetWorkloadService` target, availability result, environment, command builder, update
previews, and refresh path as the SDK Manager. Because both surfaces start from the same root and
the same prerelease-aware ordering, they cannot disagree.

## Testing

Pure helpers are covered by unit tests in `tests/MauiSherpa.Workloads.Tests` (RID/URL building,
SHA-512 verify, `list`/`--info` parsing, argument building). Doctor reconciliation and the new
dependency-status shapes are covered in `tests/MauiSherpa.Core.Tests/Services/DoctorDotnetUpTests.cs`.
SHA-512 verify, `list`/`--info` parsing, argument building, install-root resolution in
`Services/DotnetSdkSourceResolverTests.cs`, and prerelease ordering in
`Models/SdkVersionTests.cs`). Doctor source-of-truth selection, channel-preview matching, and the
dependency-status shapes are covered in
`tests/MauiSherpa.Core.Tests/Services/DoctorDotnetUpTests.cs`.
3 changes: 2 additions & 1 deletion src/MauiSherpa.Core/Interfaces.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1730,7 +1730,8 @@ public record DoctorContext(
bool DotnetUpInstalled = false,
string? DotnetUpVersion = null,
string? DotnetUpManagedInstallRoot = null,
string? DotNetArchitecture = null
string? DotNetArchitecture = null,
bool UsesDotnetUpManagedSdk = false
);

/// <summary>
Expand Down
Loading
Loading