TL;DR: Local setup and day-to-day development workflows for ALPA Mobile: prerequisites, restore/build commands (dotnet build ALPAMobile/ALPAMobile.csproj -f net10.0-ios|-android), live debugging with MauiDevFlow, pre-commit git hooks (activate once withgit config core.hooksPath .githooks), and troubleshooting. Versions are centralized inglobal.jsonandDirectory.Build.props— read Version management before touching any version number.
This guide covers local setup and day-to-day development workflows for ALPA Mobile.
global.json at the repo rootInstall MAUI workloads:
dotnet workload install maui
The workload version is pinned viatools.dotnet-workload-versioninglobal.json. No--versionflag needed — it reads from the file automatically.
Verify SDK:
dotnet --version
The solution (ALPAMobile.sln) is split into n-tier projects; dependency direction is enforced by architecture tests (UnitTest/Architecture/LayerDependencyRulesTests.cs).
ALPAMobile/ — Legacy MAUI head (assembly ALPADocs); app entry point and remaining legacy services. References all four tier projects.ALPAMobile.Domain/ — Domain entities and core business rules. References no other tier.ALPAMobile.Application/ — Use cases and service interfaces. References Domain only.ALPAMobile.Infrastructure/ — REST, storage, and platform service implementations. References Application and Domain.ALPAMobile.Presentation/ — Blazor Hybrid UI (Razor pages/components). References Application and Domain.UnitTest/ — Unit tests, including UnitTest/Architecture/ layer-dependency and docs/CSS integrity suitesUITests.Shared/ — Shared UI test logicUITests.iOS/ — iOS UI test projectUITests.Android/ — Android UI test projectDocsNavTests/ — Browser click-through tests for the docs site navigationdocs/ — Technical and product documentationwiki/ — Azure DevOps wiki content# Restore
dotnet restore ALPAMobile.sln
# iOS debug build
dotnet build ALPAMobile/ALPAMobile.csproj -f net10.0-ios -c Debug
# Android debug build
dotnet build ALPAMobile/ALPAMobile.csproj -f net10.0-android -c Debug
Use your normal IDE workflow for deploying to simulator/device.
# iOS debug build
dotnet build ALPAMobile/ALPAMobile.csproj -f net10.0-ios -c Debug
# Android debug build
dotnet build ALPAMobile/ALPAMobile.csproj -f net10.0-android -c Debug
MauiDevFlow enables live UI inspection, screenshots, tapping, log streaming, and network monitoring against a running simulator or emulator — no IDE required.
dotnet tool install --global Redth.MauiDevFlow.CLI
dotnet tool install --global appledev.tools # iOS / Mac Catalyst
dotnet tool install --global androidsdk.tool # Android
# Keep the MauiDevFlow skill definition up to date
maui-devflow update-skill
The Redth.MauiDevFlow.Agent package is already included in the project and registered in MauiProgram.cs under #if DEBUG. Build and launch normally — the agent starts automatically on debug builds.
iOS Simulator:
dotnet build ALPAMobile/ALPAMobile.csproj -f net10.0-ios
dotnet build ALPAMobile/ALPAMobile.csproj -f net10.0-ios -t:Run \
-p:_DeviceName=:v2:udid=<SIMULATOR_UDID>
# Find your simulator UDID
xcrun simctl list devices booted
Android Emulator:
dotnet build ALPAMobile/ALPAMobile.csproj -f net10.0-android -t:Run
maui-devflow wait # prints the port when connected
maui-devflow MAUI status # confirm connection
maui-devflow MAUI screenshot --output screen.png # capture screen
maui-devflow MAUI tree --depth 15 # visual element tree
maui-devflow MAUI logs --follow # stream live logs
maui-devflow MAUI network # monitor HTTP requests
maui-devflow MAUI tap --automationId "MyButton" # interact with UI
MauiDevFlow targets the MAUI app shell — it needs the Redth.MauiDevFlow.Agent registered in MauiProgram.cs and a running simulator/emulator to attach to. Plain-web Blazor Server surfaces with no MAUI host at all — e.g. PreviewHost (its own repo, ALPAMobile.PreviewHost, since AB#2309), the admin-portal WYSIWYG preview seed — have no agent to attach to, so MauiDevFlow doesn't apply. Playwright fills the equivalent role there: drive a real browser engine against the running dotnet run server and inspect/interact with it programmatically.
Why this matters, not just "nice to have": curl can fetch served HTML/CSS and confirm a build's output bytes are correct, but it cannot execute JavaScript or simulate pointer, drag, or wheel events at all. Any interactive behavior — carousel drag-to-scroll, scroll chaining, drag-and-drop — is invisible to curl no matter how carefully you inspect the markup. A real "it works" claim for that class of bug requires actually driving a browser.
# Playwright itself is available via npx — no persistent install needed
npx --yes playwright install chromium # first run only, per machine
# WebKit — the closest available engine to Safari when Chromium-only
# testing isn't enough to explain a Safari-specific report
npx --yes playwright install webkit
Write a small throwaway .mjs script (scratchpad directory, not committed) and run it with node against the already-running dotnet run instance:
import { chromium } from 'playwright';
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 500, height: 950 } });
await page.goto('http://localhost:5311/home', { waitUntil: 'networkidle' });
// Real pointer drag — mouse.move/down/move/up, not a single jump —
// is what actually exercises mousemove-driven drag handlers.
const box = await (await page.$('.alpa-feed-track')).boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x + box.width / 2 - 150, box.y + box.height / 2, { steps: 10 });
await page.mouse.up();
// Real wheel event, for scroll-chaining bugs mouse-drag can't surface
await page.mouse.wheel(0, 300);
await page.waitForTimeout(150); // see gotcha below — read too early and you'll see stale state
page.mouse.wheel(...) returns before the browser has necessarily applied the resulting scroll — reading element.scrollTop immediately after can read stale (pre-event) state and produce a false "still broken" result. Add a short page.waitForTimeout(100–200) before asserting.dragstart/dragover/drop events, not raw mouse.move/down/up deltas — that sequence works for scroll-drag (which only needs mousemove) but not for draggable="true" elements. Use page.dragAndDrop(source, target), or dispatch DragEvents manually if that proves unreliable against server-round-tripped handlers (Blazor Server).scroll-snap-type: mandatory only visibly fights a short drag — a long one sails past the snap point and looks fine either way). Vary the distance/duration, don't just confirm the happy path once.do JavaScript requires enabling "Allow JavaScript from Apple Events" in Safari's Develop settings — don't flip that setting without asking first). When Chromium and WebKit both pass but a real-Safari report persists, rule out browser cache (hard refresh) and confirm the exact input device/gesture with the person reporting it before assuming a residual engine-specific bug.# Add or update dependencies
dotnet restore
# Build all solution projects
dotnet build ALPAMobile.sln
After feature changes:
docs/ folderPre-commit hooks enforce code-quality gates. Activate once per clone / worktree:
git config core.hooksPath .githooks
Hooks run in order at commit time:
| # | Check | Triggers on | Bypass variable |
|---|---|---|---|
| 1 | Doc timestamp discipline | staged docs/component-specifications/*.html | SKIP_TIMESTAMP_CHECK=1 |
| 2 | Secret / credential scan | all staged files | SKIP_SECRET_SCAN=1 |
| 3 | Markdown lint | staged .md files | SKIP_MD_LINT=1 |
| 4 | N-Tier architecture tests | staged .cs / .razor | SKIP_ARCH_CHECK=1 |
| 5 | Full unit test suite | staged .cs / .razor | SKIP_UNIT_TESTS=1 |
| 6 | Video-asset placement — staged video files (.mp4/.mov/…) must live under docs/ (documentation, e.g. walkthrough replay clips) so no csproj glob can ship them in the app bundle | staged video files | SKIP_MEDIA_CHECK=1 |
Bypass variables may be combined. Document the reason in the commit message when bypassing. Build smoke is handled by CI — not duplicated in the hook.
Manual timestamp validation (outside of a commit):
bash docs/validate-doc-timestamps.sh # staged files only
bash docs/validate-doc-timestamps.sh --all # full spec corpus
export ANDROID_HOME="$HOME/Library/Android/sdk"
export PATH="$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools"
dotnet workload repair
dotnet nuget locals all --clear
dotnet restore ALPAMobile.sln
VS Code's maui: Build pre-launch task reports a bare exit code 155 with no further detail. This is MSBuild failing SDK resolution before it reaches any target — always reproduce with a direct dotnet build in a terminal first, which surfaces the real error VS Code swallows:
dotnet --version # if this alone fails to resolve, the SDK pinned in global.json isn't installed
If dotnet --version reports "Requested SDK version … not found", the sdk.version pinned in global.json (rollForward: disable — exact match required) isn't installed on this machine. Install it — see Installing a pinned SDK with dotnetup — or, if the pin itself is wrong (e.g. a merge/rebase brought in a version nobody has installed), point global.json at a version the team actually has and follow To upgrade stable MAUI to keep Directory.Build.props in sync.
Symptom: error: iOS code signing key '…' not found in keychain on dotnet build -f net10.0-ios -r iossimulator-arm64 -c Debug.
This should never happen for a simulator build. Per Microsoft's .NET for iOS build properties and manual-provisioning docs, code signing applies only to physical-device builds — the simulator needs no certificate or provisioning profile at all, and if CodesignKey/CodesignProvision are left unset, the build auto-selects whatever's appropriate.
If this error appears on a simulator build, the shared ALPAMobile/ALPAMobile.csproj has a CodesignKey scoped too broadly — e.g. by TargetFramework + Configuration alone, with no RuntimeIdentifier check, so it also fires for iossimulator-*. The convention going forward:
.csproj. One developer's CodesignKey checked in breaks simulator (and on-device) debug builds for everyone else the moment it's merged — this happened via a WIP pre-rebase commit that landed a personal identity on imp/blazor-hybrid.CodesignKey/CodesignProvision to the device RID only ('$(RuntimeIdentifier)' == 'ios-arm64'), so simulator debug builds never attempt signing-identity resolution in the first place.ALPAMobile/ALPAMobile.csproj.user — gitignored (*.user), auto-imported by both Visual Studio and dotnet build from the CLI, so it never needs to touch the shared file:
<!-- ALPAMobile/ALPAMobile.csproj.user (gitignored — not committed) -->
<Project>
<PropertyGroup Condition="'$(RuntimeIdentifier)' == 'ios-arm64'">
<CodesignKey>Apple Development: Your Name (TEAMID)</CodesignKey>
</PropertyGroup>
</Project>
MAUI, SDK, and workload versions are centralized — do not hardcode them in pipeline YMLs or project files.
| File | Controls |
|---|---|
global.json | .NET SDK version + workload-set version |
Directory.Build.props | $(MauiVersion) — the Microsoft.Maui.Controls NuGet package version |
sdk.version and tools.dotnet-workload-version in global.json<MauiVersion> in Directory.Build.propsSDK ↔ workload coupling: The SDK version and workload-set version must stay on the same feature band (e.g. SDK10.0.101↔ workload-set10.0.101).
NuGet package version ≠ SDK/workload version:Microsoft.Maui.ControlsNuGet packages follow a separate version sequence (e.g.10.0.51) that does not match the SDK version (10.0.101). Always verify the NuGet package version exists on nuget.org before setting it inDirectory.Build.props.
useMauiNightly = truemauiNightlyVersion (e.g. 10.0.60-ci.main.26203.10)This overrides only the NuGet package version at build time — the SDK and workload-set stay on the stable version in global.json.
Updating global.json's sdk.version/tools.dotnet-workload-version (see To upgrade stable MAUI) only changes what the repo expects — it does not install anything. rollForward: disable means an exact match is required, so every machine that builds this repo needs the matching SDK actually installed before dotnet build will resolve at all.
This repo's dev machines manage .NET installs with dotnetup (user-level installs, multiple SDK bands side by side — not the system-wide installer). To install a new pinned SDK band and bring its MAUI workloads up to date:
# Install the SDK band pinned in global.json (repeat per new version bump)
dotnetup sdk install <sdk.version> --set-default-install
# Update/install the matching MAUI workloads for that band
dotnet workload update --version <tools.dotnet-workload-version>
Example, upgrading to SDK 10.0.400:
dotnetup sdk install 10.0.400 --set-default-install
dotnet workload update --version 10.0.400.1
--set-default-installupdatesPATH/DOTNET_ROOTso the new band becomes the defaultdotneton this machine — required forglobal.jsonresolution to find it. Ifdotnetupordotnetaren't yet onPATH(fresh machine, before firstdotnetup init), run them via the dotnetup-managed absolute paths instead:~/.dotnetup/dotnetup sdk install …and~/Library/Application\ Support/dotnetup/dotnet/dotnet workload update …(macOS; installed under~/.dotnetupand dotnetup's manageddotnetdirectory).
If a dotnet workload update reports nothing to update because no workloads are installed at all for that SDK band yet, restore them from the project instead — this reads the project's target frameworks and installs exactly the workloads it needs:
dotnet workload restore ALPAMobile/ALPAMobile.csproj