Skip to content

Add .csproj "project mode" to winapp run#612

Draft
nmetulev wants to merge 16 commits into
winui-devexfrom
nmetulev-run-csproj-project-mode
Draft

Add .csproj "project mode" to winapp run#612
nmetulev wants to merge 16 commits into
winui-devexfrom
nmetulev-run-csproj-project-mode

Conversation

@nmetulev

Copy link
Copy Markdown
Member

Summary

Implements .csproj "project mode" for winapp run per the committed spec specs/winapp-run-csproj.md (v0.4).

winapp run <MyApp.csproj> — or winapp run . / a folder containing a single executable .csproj — now builds the WinUI project and launches it in one step, supporting both packaged and unpackaged WinUI apps. Existing folder mode is unchanged (all new options are optional; project mode is a new branch keyed on the input pointing at / containing a top-level buildable .csproj).

Draft: targets winui-devex (not main).

What's implemented (per spec §12 phases)

  1. Input model + routing — positional arg widened DirectoryInfoFileSystemInfo; ResolveInput classifies Folder vs Project; multi-.csproj ambiguity error (prefers the evaluated OutputType over the static XML parse in ProjectDetectionService).
  2. Build integration + property resolutiondotnet build <csproj> -t:Build --getProperty:... (with the required -t:Build); MsBuildPropertyReader handles both the JSON (multi-prop) and raw scalar (single-prop) shapes; repeatable -p/--property forwarded to both build and evaluation; dedicated-flag-beats--p precedence mirrored.
  3. Unpackaged launch + runtime reuse — detection off effective WindowsPackageType (None ⇒ unpackaged); launches the apphost RunCommand exe directly via new AppLauncherService.LaunchExecutable; reuses the existing WinAppSDK runtime install (EnsureWindowsAppRuntimeInstalledAsync), gated on WindowsAppSDKSelfContained.
  4. Packaged launch — loose-layout registration on the absolute TargetDir + AUMID launch (reuses the folder-mode pipeline); guardrail error when a project resolves to packaged but has no Package.appxmanifest.
  5. Arch-correct runtime installWorkspaceSetupService.InstallWindowsAppRuntimeAsync parameterized by the app's resolved --arch (was CLI-process arch only), with a Framework+DDLM presence check (GetInstalledVersion(name, architecture)) to avoid needless reinstalls.
  6. Docs / samples / tests — see below.

Scope guards: .NET-only; no --project / --packaged / --unpackaged flags. Unpackaged runs reject identity-only options (--manifest, --no-launch, --with-alias, --clean, --unregister-on-exit, --output-appx-directory). --getProperty availability probed via runtime capability (SDK 8.0.100+), erroring clearly otherwise.

New CLI options (project mode only; ignored in folder mode)

-c/--configuration (default Debug), --arch, -r/--runtime, -f/--framework, --no-build, --no-restore, -p/--property (repeatable).

Test coverage

C# unit/routing (144 pass, Release):

  • MsBuildPropertyReaderTests — JSON/scalar/preamble/empty/case/missing-prop parsing.
  • RunArchHelperTests — arch normalization, RID/Platform mapping.
  • ProjectRunServiceTestsBuildDotnetArguments (default/arm64/--no-build/user--p:Platform suppression/framework), SDK-version parsing, ResolveInput incl. multi-csproj ambiguity.
  • RunCommandProjectModeTests (13) — unpackaged launch + runtime-arch, arm64, self-contained skip, identity-option rejection, forced-unpackaged property forwarding, packaged AUMID + arch, packaged-no-manifest guardrail, SDK-too-old, build-fail exit-code propagation, invalid-arch, resolve ambiguity, folder-mode regression.
  • RunCommandTests — project-mode option parsing + TryResolveArchitecture; existing folder-mode tests unchanged.

Pester sample & guide (spec §10):

  • C2 (must-have) — samples/winui-unpackaged-app: new minimal unpackaged WinUI app; test builds + winapp run . and asserts the app boots off the reused runtime install (verified locally: app process stays alive). Also asserts evaluated WindowsPackageType == None.
  • samples/winui-app (packaged): project-mode winapp run . --no-launch from a clean copy + evaluated WindowsPackageType != None.
  • Both registered in the .github/workflows/test-samples.yml matrix.

Docs / samples updated

docs/usage.md (run section: folder-vs-project auto-detection + project-mode subsection), docs/guides/dotnet.md, docs/fragments/skills/winapp-cli/setup.md (hand-written template; generated SKILL.md / cli-schema.json / .claude regenerated via generate-llm-docs.ps1), new samples/winui-unpackaged-app.

Deferred / notes

  • Q6 (spec's only open item): the NuGet/MSBuild dotnet run hook stays folder-based — unchanged, as decided.
  • npm binaries & full AOT build-cli.ps1 were not run locally: the AOT publish needs the MSVC linker env (missing in this sandbox) and npm bin/ blobs are gitignored/CI-produced. The CLI + tests build clean in Release (0 warnings / 0 errors, warnings-as-errors on), and cli-schema.json / skills were regenerated with the correct 0.4.1 version. CI's Build & Package workflow will produce the AOT artifacts.

Co-authored-by: Copilot App 223556219+Copilot@users.noreply.github.com

Implements project mode for winapp run per specs/winapp-run-csproj.md (v0.4):
point run at a .csproj (or a folder/'.' containing a single executable csproj)
to build and launch a WinUI app, supporting both packaged and unpackaged apps.
Folder mode is unchanged.

- Input model + folder/project routing with multi-csproj ambiguity error
- Build integration + MSBuild property resolution (JSON/scalar --getProperty)
- Unpackaged launch via RunCommand exe with reused Windows App Runtime install
- Packaged launch via loose-layout registration + AUMID
- Arch-correct runtime install with Framework+DDLM presence check
- New AppLauncherService.LaunchExecutable for direct exe launch
- Docs (usage, dotnet guide, setup skill), unpackaged WinUI sample, tests

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Build Metrics Report

Binary Sizes

Artifact Baseline Current Delta
CLI (ARM64) 31.87 MB 31.83 MB 📉 -46.0 KB (-0.14%)
CLI (x64) 32.19 MB 32.15 MB 📉 -44.0 KB (-0.13%)
MSIX (ARM64) 13.39 MB 13.35 MB 📉 -36.4 KB (-0.27%)
MSIX (x64) 14.22 MB 14.19 MB 📉 -34.1 KB (-0.23%)
NPM Package 27.91 MB 27.87 MB 📉 -48.3 KB (-0.17%)
NuGet Package 27.94 MB 27.89 MB 📉 -50.3 KB (-0.18%)

Test Results

1678 passed, 1 skipped out of 1679 tests in 438.8s (+67 tests, +16.4s vs. baseline)

Test Coverage

18.4% line coverage, 37.9% branch coverage · ✅ no change vs. baseline

CLI Startup Time

39ms median (x64, winapp --version) · ✅ -5ms vs. baseline


Updated 2026-07-11 06:03:39 UTC · commit c2e8028 · workflow run

nmetulev and others added 15 commits July 10, 2026 14:37
Start-Process -NoNewWindow launches via CreateProcess, which cannot
execute the npm winapp.cmd batch shim directly and fails with Win32
error 193 ("%1 is not a valid Win32 application") on CI runners where
winapp on PATH is a .cmd. Route the PATH-winapp case through cmd.exe (a
real Win32 host) so the shim runs; cmd /d /c still propagates winapp's
exit code. Product/project-mode launch code is unaffected.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Start-Process -Wait blocks until the launched process AND all its
descendants exit. In project mode, winapp launches the unpackaged app as
a descendant that runs indefinitely, so -Wait never returns and the test
step hangs (previously masked on CI by the earlier Win32 error 193, which
failed the launch before -Wait was reached).

Drop -Wait and instead wait only on winapp's own process via
$proc.WaitForExit(), which does not wait for descendants. --detach makes
winapp return as soon as it has built and launched the app, so this
captures winapp's exit code without waiting on the app.

Verified end-to-end against a winapp.cmd shim (reproducing the CI
condition): the launch returns 0 after the build, the app is found by
name and stays alive past 3s, and the script completes without hanging.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses PR #612 review findings on the highest-risk runtime + launch paths.

H1 (arch-correctness): the reused Windows App Runtime install is now truly
arch-correct with a real presence gate. WorkspaceSetupService.InstallWindows-
AppRuntimeAsync separates the loose-layout/inventory arch (resolved target arch,
used for the win10-<arch> path and ParseMsixInventoryAsync) from the nullable
skip-filter arch, and a new IsWindowsAppRuntimeRegistered(arch) checks for BOTH
a Framework package (Microsoft.WindowsAppRuntime.*, excluding .CBS.) AND a DDLM
(Microsoft.WinAppRuntime.DDLM.*), arch-filtered — mirroring WinAppSDK's
IsRuntimeRegisteredForCurrentUser. EnsureWindowsAppRuntimeInstalledAsync now
throws if the target-arch runtime is still absent after install instead of
treating a missing runtime dir as success, so a cross-arch run no longer skips
the install and crashes at bootstrap.

L2 (folder mode unchanged): the process-arch handling is scoped so folder mode
passes a null arch filter — byte-for-byte identical to the pre-feature behavior.

M3 (PID-reuse / exit-code race): LaunchExecutable now returns an owned
ILaunchedProcess wrapper (new ILaunchedProcess.cs) instead of a bare pid, so the
unpackaged wait/detach/debug flow holds the real Process handle and reads its
exit code directly rather than re-attaching via Process.GetProcessById (which
could hit a reused pid or an already-exited process wrongly reporting 0).

Adds WorkspaceSetupServiceRuntimeGateTests and updates the fakes.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…2, M4, M5)

Addresses PR #612 review findings on project-mode property resolution.

H2 (--json unparseable): stdout in --json mode is now pure JSON. The human-
readable "Building..." banner is gated behind !Json and dotnet build diagnostics
on a failed build are routed to stderr, so a --json consumer's stdout parses.
Adds banner-suppression tests (json vs non-json).

M1 (arg escaping): BuildDotnetArguments uses WindowsCommandLine.JoinArguments
instead of a hand-rolled QuoteIfNeeded that mishandled trailing backslashes.

M2 (--no-build precedence): user -p properties are emitted before the dedicated
-c/--arch/-f flags on the --no-build path too, so the dedicated flag wins
last (matching the build path and WarnOnOverriddenFlags). Adds a precedence test.

M4 (getProperty parse robustness): MsBuildPropertyReader scans each '{' and uses
Utf8JsonReader/TryParseValue, tolerating leading diagnostics/preamble and
trailing content, and only accepts a Properties object — so a single-property
scalar that merely contains '{' is no longer misread as JSON. Adds parser tests.

M5 (multi-project disambiguation): adds coverage for a test project (IsTest-
Project=true) being excluded from the executable set and for the
no-executable-among-multiple ambiguity error. The evaluated OutputType remains
the source of truth downstream (BuildAndResolveAsync rejects non-runnable
OutputType), with the static parse only used to disambiguate at input time.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses PR #612 review findings.

M8: regenerates src/winapp-npm/src/winapp-commands.ts from the current CLI
schema so it includes the run project-mode options (--arch, --no-build,
--no-restore, -r/--runtime, -c/--configuration, -f/--framework, -p). The
generate-commands --check gate now passes.

H3: samples/winui-app/test.Tests.ps1 registered a loose-layout package then
deleted its backing files without unregistering, polluting the machine/CI. The
AfterAll now reads the identity Name from Package.appxmanifest and removes the
package (Get-AppxPackage | Remove-AppxPackage) before deleting temp/bin/obj.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds BuildAndResolveAsync unit tests that lock down the spec §7.1 packaged-vs-
unpackaged detection at the service level:
- WindowsPackageType=None resolves to Unpackaged with the RunCommand apphost .exe
  as the launch target and SelfContained=false.
- Empty WindowsPackageType with EnableMsixTooling=true (the --no-build evaluate-
  only path, where MSIX targets don't run) falls back to Packaged.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Adds a BuildAndResolveAsync test proving that in --json mode a failed build's captured dotnet diagnostics do not reach stdout (they go to stderr) and the exit code propagates, so a --json consumer's stdout stays parseable.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
M5 (behavioral): multi-.csproj disambiguation now classifies each
candidate via an MSBuild evaluation of OutputType/IsTestProject
(--getProperty) so projects whose type comes from an import
(Directory.Build.props, SDK defaults, the test SDK) are detected
correctly, instead of a static XML parse that could silently run the
wrong project. Falls back to the static parse when the SDK/restore is
unavailable. ResolveInput -> ResolveInputAsync. Folder mode still
performs NO evaluation, so it stays byte-for-byte identical.

M6 (tests): drive BuildAndResolveAsync + CheckSdkAsync through
FakeDotNetService -- non-exe OutputType throw, empty TargetDir throw,
unpackaged-missing-RunCommand throw, --no-build hint, happy-path arch
resolution; SDK not-found / non-zero / too-old / capable / newer /
unparseable. One case feeds a '{'-containing build preamble to lock in
the M4 getProperty-parse fix.

M7 (tests): WorkspaceSetupServiceInventoryTests pins
ParseMsixInventoryAsync to the TARGET arch (not the host) and asserts a
missing/empty inventory does not report success -- the gate behind H1.

L3: --property arity ZeroOrMore -> OneOrMore (rejects a bare -p) plus a
handler-side Name=Value check (rejects a value with no '='). +2 tests.

L4: add winui-unpackaged-app and winui-app rows to the README sample
table.

L5: extract Fail(message, isJson) and convert the repeated
log-error + PrintJson + return-1 sites in RunCommand project mode.

Regenerate docs/cli-schema.json for the --property arity change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Locks in the M3 fix (owned ILaunchedProcess handle, waited via
WaitForLaunchedProcessAsync). Drives the unpackaged wait/exit-code path
without --detach and asserts a non-zero app exit (42) is reported as a
failure rather than masked as success, the owned handle is disposed, and
a normally-exiting app is not killed. FakeAppLauncherService.FakeExitCode
was previously unused, so this closes the gap.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Closes the remaining M7 sub-gap: GetInstalledVersion/IsPackageInstalled
apply their arch filter based on MapArchitecture(architecture). The live
WinRT PackageManager comparison can't be faked, but the decision of
whether to filter at all is entirely MapArchitecture's null-ness — so
expose it as internal and pin:
 - known arch strings (x64/arm64/x86, case/space-insensitive) map to the
   WinRT ProcessorArchitecture used for project-mode arch-correctness
 - null/empty/whitespace/unrecognized => null => NO filter, which is how
   folder-mode run (architecture == null) keeps matching any installed
   package incl. Neutral-arch ones (spec L2, #1 hard constraint).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Hardening + test/DX follow-ups from the round-2 pr-review (0 Crit / 0 High /
3 Med / 6 Low, none blocking). Folder mode stays byte-for-byte identical.

Fixed:
- R2-M1 (med): version-specific runtime gate. InstallWindowsAppRuntimeAsync now
  also returns the versioned Framework/DDLM identities from the resolved
  inventory; IsWindowsAppRuntimeRegistered takes optional expected names and, in
  addition to the generic Framework+DDLM presence check, requires each expected
  identity for the arch. Closes the false-pass where a DIFFERENT WinAppSDK
  version registered for the arch masked a silently-failed version-specific
  install (unpackaged path only; empty/null = legacy behavior).
- R2-M2 (med): FakeMsixService gains an EnsureRuntimeInstalledException hook +
  a RunCommandProjectModeTests case asserting a runtime-prep throw aborts with a
  non-zero exit and never launches the app.
- R2-M3 (med): generate-commands.mjs emits string | string[] for array-typed
  named options (C# Option<T[]>) and pushes one flag per element, so run's
  repeatable -p/--property is usable from the npm SDK. Regenerated
  winapp-commands.ts.
- R2-L1: replace the tautological CBS assertion with a real-name discrimination
  test (.CBS. matches the CBS component name, not a Framework name).
- R2-L2: WarnOnOverriddenFlags now warns when a user -p:Platform overrides the
  --arch-derived Platform (opposite precedence to Configuration/RID).
- R2-L4: reworded --runtime help to clarify only the RID's architecture is used;
  hand-synced docs/cli-schema.json + setup skills (version unchanged).

Won't-fix (documented):
- R2-L3: valueless -p is rejected by the OneOrMore parser as a standard CLI
  error; parser-level errors are non-JSON framework-wide (consistent with L5).
- R2-L5/L6: pre-existing shared-command gaps, not introduced by this PR.

Build: Release warnings-as-errors clean. Tests: 1667 pass / 1 env-skip
(+4 new). npm format/lint/compile + generate-commands:check green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The repeatable --property/-p option used OneOrMore arity, so a bare '-p'
(no Name=Value) failed as a System.CommandLine parser arity error. Parser
errors are rendered as plain text by the shared invocation path, so under
--json callers got no structured error (only text + exit 1).

Move the valueless-'-p' rejection into the run handler so it flows through
the command's own --json error envelope:

- RunCommand.cs: relax --property arity OneOrMore -> ZeroOrMore, then detect
  a valueless occurrence from the raw OptionResult (IdentifierTokenCount >
  Tokens.Count -- one identifier token per '-p', so more identifiers than
  captured values means at least one '-p' had no argument) and route it
  through Fail(), which emits the JSON error envelope under --json. This is
  local to run and does not touch the shared parser path (the pre-existing
  L5/L6 gaps). It also fixes OneOrMore's greedy '-p --detach' consumption
  (a bare '-p' no longer swallows the following option token).
- cli-schema.json: sync run --property arity minimum 1 -> 0 to match the
  ZeroOrMore the AOT CLI now emits (validate-docs regenerates + compares).

Tests (RunCommandProjectModeTests):
- ProjectMode_ValuelessProperty_Errors: now asserts the handler-level failure.
- ProjectMode_ValuelessProperty_Json_EmitsJsonError: new; asserts a valueless
  -p under --json yields a JSON envelope with an Error field.
- ProjectMode_RepeatableProperty_Succeeds: new; guards the repeatable-'-p'
  happy path against regression.

Folder mode is unaffected: without -p the option result is null and the new
check is skipped, so behavior is byte-identical.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round-3 re-review of the project-mode runtime presence gate surfaced two
residuals of the now-fixed R2-M1 version-specific gate. Both are in
PR-introduced code, so fix them to reach zero findings.

M1-residual (MEDIUM): the gate required each expected Framework/DDLM
identity to be *present by name* (StartsWith), but the Framework family
name is only `major.minor`. A stale OLDER patch of the same minor
(e.g. 1.8.A registered, app built against 1.8.B whose install silently
failed) satisfied the name check and false-passed the gate -> possible
MinVersion crash at bootstrap. Thread the required version alongside the
name and require `GetInstalledVersion(name, arch)` to parse and be
>= the required version. Lenient presence fallback when either version
string is unparseable. Folder-mode / legacy callers pass no expected
list -> generic presence check only, byte-identical.

- InstallWindowsAppRuntimeAsync now returns
  IReadOnlyList<(string Name, string Version)> RuntimePackages (was
  IReadOnlyList<string>), carrying the inventory NewVersion per identity.
- IsWindowsAppRuntimeRegistered takes
  IReadOnlyList<(string Name, string Version)>? and compares versions.
- Updated IWorkspaceSetupService, MsixService.Identity (public + private
  overloads), and FakePackageRegistrationService (per-name/arch version
  hook, records arch).

L1-residual (LOW): when GetRuntimeMsixDirAsync returns null the expected
identity list is empty, so the gate falls open to the generic prefix
check. This path is project-mode unpackaged (non-self-contained), where a
framework-dependent app always needs a runtime, so surface a loud
(non-verbose) warning that the exact runtime couldn't be version-verified.
The generic gate still fails closed when nothing is registered at all.

Tests: version-aware gate cases in WorkspaceSetupServiceRuntimeGateTests
(older-patch -> false, newer-patch -> true, updated present/different-
version cases assert via GetInstalledVersion + arch forwarding). Full C#
suite 1671 pass / 1 pre-existing env-skip; Release warnings-as-errors 0W/0E.

No public CLI surface / schema / npm change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Round-4 pr-review flagged the unpackaged runtime presence gate as
over-strict on the DDLM identity. Because DDLM package names embed the
FULL version (e.g. Microsoft.WinAppRuntime.DDLM.8000.806.2252.0-x64) and
install side-by-side, the per-identity exact-name GetInstalledVersion
lookup effectively demanded the app's EXACT DDLM. If that specific DDLM's
install silently failed while a newer compatible DDLM for the same
framework minor was present, the gate would false-FAIL an otherwise
launchable app.

Fix: apply the exact-identity + version>=required check to the app-facing
Framework family only; rely on the generic DDLM prefix-presence check
(already run at the top of the gate) for the DDLM. The Framework version
compare remains the authoritative patch-level guard (still closes the
R2-M1 stale-older-patch false-pass); DDLMs track the Framework, so a
present DDLM plus the correct Framework version is sufficient.

- WorkspaceSetupService: extract IsFrameworkGatePackageName helper (reused
  by IsRuntimeGatePackageName); skip non-Framework (DDLM) entries in the
  per-identity loop with a rationale comment.
- IWorkspaceSetupService / IsWindowsAppRuntimeRegistered docs updated to
  describe Framework-exact vs DDLM-generic matching.
- Tests: +2 (older expected DDLM not registered + newer present + Framework
  ok -> gate TRUE and DDLM NOT exact-looked-up; Framework-vs-DDLM prefix
  discrimination). Full suite 1673 pass / 1 env-skip; Release 0W/0E.

Folder mode byte-identical (no expected list -> loop skipped entirely).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
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.

1 participant