Skip to content

Integrate the WinUI 3 XAML language service into the winapp extension#50

Draft
nmetulev wants to merge 9 commits into
mainfrom
nmetulev/winui
Draft

Integrate the WinUI 3 XAML language service into the winapp extension#50
nmetulev wants to merge 9 commits into
mainfrom
nmetulev/winui

Conversation

@nmetulev

Copy link
Copy Markdown
Member

Description

Integrates the WinUI 3 XAML language service into the shipping winapp VS Code extension, bringing the WinUI developer experience in VS Code closer to parity with Visual Studio. This is folded into the existing extension as a single unified extension (not a second extension) and is purely additive — the existing winapp CLI commands, winapp debug provider, and AppxManifest custom editor are untouched.

What lands:

  • LSP client module (src/xaml/) wired into the existing activate()/deactivate(); activates on onLanguage:xaml, launches the .NET LSP server over stdio, resolves the server DLL in priority order (setting → env var → bundled → repo build), and registers winui-xaml.showInfo / winui-xaml.restartServer.
  • .NET LSP server (server/ — 3 projects: WinUiXaml.LanguageServer, WinUiXaml.Workspace, WinUiXaml.Xaml) providing completion, hover, F12/definition, diagnostics, references, rename, code actions, formatting, semantic tokens, folding, links, etc. Backed by Roslyn MSBuildWorkspace + a tolerant XAML parser.
  • Language contributions: the xaml language, TextMate grammar, and language-configuration.json (isolated under a subfolder), plus config props and the vscode-languageclient runtime dependency.
  • Build/packaging: publishing the .NET server as a bundled asset and the corresponding .vscodeignore handling.
  • Tests: the .NET xUnit suites, the raw-stdio lsp-smoke harness, and the Mocha/@vscode/test-electron integration harness driving real LSP features against a committed WinAppSDK fixture.

Usage Example

Opening any .xaml file in a trusted workspace activates the language service:

// settings.json — optional overrides
{
  "winui-xaml.server.path": "C:\\path\\to\\WinUiXaml.LanguageServer.dll",
  "winui-xaml.server.dotnetPath": "C:\\Program Files\\dotnet\\dotnet.exe"
}

Commands: WinUI XAML: Show Info (winui-xaml.showInfo), WinUI XAML: Restart Server (winui-xaml.restartServer).

Related Issue

N/A

Type of Change

  • ✨ New feature
  • 🔧 Config/build
  • 🧪 Test update

Checklist

  • New tests added for new functionality (server xUnit boundary/behavior tests, lsp-smoke, and vscode integration coverage)
  • Ran .\scripts\build-vsce.ps1 locally — see Additional Notes for the exact build/test commands that were run
  • Tested locally on Windows (full integration suite green)
  • README.md updated
  • package.json contribution points / command/debugger/custom-editor docs updated (languages, grammars, commands, config props, onLanguage:xaml activation)

Additional Notes

Why it is a draft. Opening for review of the integration approach and, in particular, the workspace-trust security boundary (see below). A few decisions are still open and called out below.

Verification run locally (Windows, .NET 10 SDK):

  • build:server:debug — 0 warnings / 0 errors
  • test:server — 505 passing / 0 failing
  • test:unit — 595 passing / 0 failing
  • test:xaml-smoke — green (raw-stdio LSP smoke, incl. an out-of-root boundary case)
  • full integration (npm test) — 1605 passing / 0 failing / 28 pending

Review history. This branch went through 6 automated fix→review iterations with an independent cross-model reviewer (a different model family from the author) plus two full multi-dimension review gates. The reviews drove convergence to zero findings at every severity. The final independent review is clean.

Security — workspace-trust boundary (please review closely). The server only MSBuild-evaluates .csproj projects that live under a trusted workspace-folder root (the client passes the roots as initializationOptions.allowedRoots). Candidate paths are canonicalized with reparse points (junctions/symlinks) resolved before the containment check, including for not-yet-created files, so a link inside a trusted root cannot escape to evaluate an external project. In an untrusted or empty window, XAML gets syntax highlighting only (no project evaluation) with a "Manage Workspace Trust" prompt; full IntelliSense activates once trust is granted. This boundary took several review rounds to harden and is the area most worth a careful look.

PR size. ~199 files / +51.7k lines is dominated by the vendored .NET server source and its test suites; the extension-side (TypeScript) change is small and additive.

Open decisions / follow-ups (not blocking this draft):

  • Server delivery: build-from-source vs. download a signed pipeline artifact (mirroring scripts/download-cli.ps1), and reflecting the server build/sign/publish steps in .pipelines/ for CI + code signing.
  • Runtime prerequisite: require a machine dotnet (current behavior, configurable via winui-xaml.server.dotnetPath) vs. ship a self-contained server.
  • Visualizer (live XAML preview) and the classic vs-extension/ experiment were intentionally left out of scope (phase 2); they depend on native surface bits and do not migrate cleanly.

AI Description

This section is auto-generated by AI when the PR is opened or updated. To opt out, delete this entire section including the marker comments.

nmetulev and others added 7 commits July 12, 2026 12:37
Folds the hardened XAML language service prototype (nmetulev/winui-vsc) into the shipping winapp VS Code extension as a single unified extension. Additive and non-breaking to the existing winapp CLI / debug / manifest features.

Server: copy the .NET 10 LSP server (WinUiXaml.LanguageServer/Workspace/Xaml + 3 xUnit test projects + WinUiXaml.slnx + lsp-smoke) under server/. Builds 0/0; 489/489 unit tests pass.

Client: add src/xaml/xamlLanguageService.ts (activateXaml/deactivateXaml, resolveServerDll bundled->dev fallback) wired additively from src/extension.ts; degrades to syntax-only on server-start failure. Contribute the xaml language, TextMate grammar, language-configuration, winui-xaml.showInfo|restartServer commands, winui-xaml.server.* config, onLanguage:xaml activation, and the vscode-languageclient ^9 dependency in package.json.

Tests: port the 81-file integration harness to test/xaml-integration/ (kept as source of truth) with a clean committed WinAppSDK fixture at test/fixtures/xaml/fixture/ and a root .vscode-test.mjs. Full suite: 1596 passing / 28 pending / 0 failing. smoke.mjs and features.test.js (218) green.

Packaging/CI: esbuild + package-vsc.ps1 publish the server into dist/server (bundled in the VSIX); .vscodeignore excludes C# sources/tests; release-vsc.yml adds UseDotNet@2, a server publish step, and a conditional ESRP DLL-signing step for our own WinUiXaml.*.dll.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03c713cc-41ae-4c28-ab49-3c851aa8e2bd
Automated fix->review loop iteration 1 — resolves all 17 findings from the
post-migration PR review (2 High, 9 Medium, 6 Low):

- H1 security: machine-scope winui-xaml.server.path/dotnetPath + workspace-trust
  gate (isTrusted + onDidGrantWorkspaceTrust) + capabilities.untrustedWorkspaces
- H2: deterministic fixture NuGet restore (restore:fixture in pretest)
- M1: run XAML/server/smoke suites in CI with .NET 10 (setup-dotnet)
- M2: document .NET-dependent suites; unit tests stay dotnet-free
- M3/M4: README Features + Requirements for the XAML language service
- M5: serialize client start/stop lifecycle (no torn-down pending start)
- M6: handle workspace/didChangeWatchedFiles (invalidate stale Roslyn cache)
- M7: user-facing "degraded to syntax-only" notification with actions
- M8: client command/lifecycle/degradation integration tests
- M9: fold server bundling into vscode:prepublish (ensure-server-bundle.mjs)
- L1: share bundled-asset path resolver (firstExistingPath)
- L2: tighten .vscodeignore + VSIX-contents assertion
- L3/L4: README command table + CONTRIBUTING .NET 10 SDK prereq
- L5/L6: docs/RELEASE.md signing checklist + server/nuget.config feed pin

Verified: tsc clean, eslint 0 errors, server build 0/0, 489/489 server tests,
smoke PASS, integration 1601 passing / 0 failing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03c713cc-41ae-4c28-ab49-3c851aa8e2bd
Automated fix->review loop iteration 2 — resolves the 5 residual/new findings
the independent GPT-5.6 review raised on iteration 1:

- N1 (CRITICAL security): do not start the semantic server while the workspace
  is untrusted (Roslyn/MSBuild would evaluate an attacker-controlled .csproj and
  could execute code). Stay syntax-only until trust is granted; test host runs
  with --disable-workspace-trust so the feature suite still exercises the server.
- N2 (correctness): replace the partial start-guard with a single lifecycle
  queue (runExclusive) so stop fully completes before any start — two servers
  can never coexist across concurrent restarts.
- N3 (correctness): null-guard workspace/didChangeWatchedFiles (changes:null).
- N4 (test-coverage): WINUI_XAML_FORCE_NO_SERVER test seam + a real absent-DLL
  degradation test (syntax-only, no throw).
- N5 (packaging): ensure-server-bundle publishes fresh by default; reuse only
  under WINUI_REUSE_SIGNED_SERVER=1 (set by CI -SkipServerBuild) to preserve
  signed DLLs, so a bare vsce package can never ship a stale server.

Verified: tsc clean, eslint 0 errors, server build 0/0, 489/489 server tests,
smoke PASS, integration 1602 passing / 0 failing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03c713cc-41ae-4c28-ab49-3c851aa8e2bd
…(P1-P3)

P1 (M5 residual): guard against server resurrection when restart races
deactivate. Add module-level `disposing` flag set synchronously at the top
of deactivateXaml and reset in activateXaml; doStart early-returns when
disposing. Replace the separate stop+start in the restartServer command and
onDidGrantWorkspaceTrust with a single restartClient() that runs
stop-then-start inside one runExclusive op (atomic; skips start if disposing).

P2: rewrite package.json untrustedWorkspaces capability description to match
actual behavior (syntax highlighting only until the workspace is trusted).

P3 (M9 residual): package-vsc.ps1 -SkipServerBuild set WINUI_REUSE_SIGNED_SERVER
into the caller's environment and never cleaned it up. Capture the prior value
before the try and restore/remove it in the finally block.

Full integration suite: 1602 passing / 0 failing.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03c713cc-41ae-4c28-ab49-3c851aa8e2bd
…packaging)

Final 8-dimension PR-review gate over the whole branch surfaced 10 findings the
per-iteration reviews (scoped to their own residuals) never looked at. Fix all.

G1 (HIGH security): the isTrusted gate is necessary but not sufficient — an empty
window is trusted, so opening a loose XAML file outside any workspace folder made
the server warm up and MSBuild-evaluate its nearest .csproj (code-exec vector).
Client now passes workspaceFolders as initializationOptions.allowedRoots; the
server (ResolveAllowedRoots/IsPathUnderAllowedRoot/ResolveIfAllowedAsync) only
runs project discovery + OpenProjectAsync for documents under a trusted root.
Empty allow-list => no project evaluation; out-of-root/untitled files are served
project-less (tolerant parser only). Boundary-aware, case-insensitive path check.
allowedRoots is authoritative when present; legacy clients fall back to rootUri.

G3 (HIGH test-coverage): add WINUI_XAML_FORCE_UNTRUSTED seam (mirrors
WINUI_XAML_FORCE_NO_SERVER) so the trust gate + onDidGrantWorkspaceTrust recovery
are exercised under the trusted harness. New client tests cover degrade+recover.

G4 (docs): document winui-xaml.server.path in README.
G5 (correctness): retain the synchronize.fileEvents FileSystemWatcher and dispose
it in doStop / failed-start catch (was leaking one per start/restart/trust cycle).
G6 (UX): untrusted degradation now shows a once-per-transition warning with a
Manage Workspace Trust action (was log-only).
G7 (test-coverage): extract pure buildDegradedNotification() + unit test message
and action mapping.
G8 (test-coverage): client tests for explicit server.path (valid -> semantic
completion; invalid -> syntax-only, no throw).
G9 (test-coverage): lsp-smoke sends didChangeWatchedFiles with omitted/null/empty
changes and asserts the server stays responsive.
G10 (packaging): drop the redundant bundle:server in package-vsc.ps1 (vscode:
prepublish/ensure-server-bundle.mjs is the single publish path).
G2 (packaging): narrow .vscodeignore to exclude only the apphost exe so Roslyn's
net472 MSBuild BuildHost.exe ships; assert a BuildHost is present in the VSIX.

Full integration 1605 passing / 0 failing; server 489, unit 595, smoke green.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03c713cc-41ae-4c28-ab49-3c851aa8e2bd
…indings)

The final 8-dimension confirmation gate (incl. a cross-model check) found 5 issues
the per-iteration reviews never scoped. Fix all 5.

G15 (HIGH security): the allow-list was purely lexical — Path.GetFullPath does not
resolve reparse points, so a junction/symlink placed inside a trusted workspace root
that targets an external directory passed the containment check, and the resolver
(FindOwningProject -> MSBuild GetOrLoadAsync) then evaluated the external .csproj.
Fix: CanonicalizePath resolves the final physical path (GetFinalPathNameByHandle) for
both the workspace roots (NormalizeRoots) and the document (IsPathUnderAllowedRoot)
before containment; falls back to the lexical full path for non-existent/untitled
docs or on any OS-call failure. A junction escaping the root now canonicalizes
outside it and is served project-less.

G11 (MEDIUM correctness): a workspace opened at a bare drive root (C:\) rejected all
its children — NormalizeRoots keeps C:\ with its trailing separator, but PathIsWithin
required path[root.Length] to be a separator (it was the first path-segment letter).
Fix: when the root already ends in a separator (bare drive/UNC root) the prefix match
is itself the boundary. The boundary-char check still guards C:\root vs C:\rootEvil.

G12 (HIGH test-coverage): the security-critical boundary had no direct test. Made
NormalizeRoots/PathIsWithin/CanonicalizePath internal (test project already has
InternalsVisibleTo) and added AllowedRootBoundaryTests (exact/child/out-of-root/
sibling-prefix/drive-root/cross-drive/case/trailing-sep/normalize-trim + a non-admin
mklink /J junction regression). Added a behavioral lsp-smoke case: a document under
os.tmpdir (outside the sole allowedRoot) gets no project-backed resolution while the
in-root fixture still resolves.

G13 (MEDIUM UX): the degraded-notification guard was a single boolean reset only on a
successful start, so after a user granted trust but the server still could not start
(e.g. no dotnet) the server-missing guidance was suppressed. Fix: track the last
degraded cause and notify once per distinct cause (untrusted vs server), still
non-nagging; reset on successful start.

G14 (LOW docs): README used the abbreviated server.dotnetPath; use the full
winui-xaml.server.dotnetPath.

Full integration 1605 passing / 0 failing; server 505, unit 595, smoke green;
server debug build 0 warnings.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03c713cc-41ae-4c28-ab49-3c851aa8e2bd
…trust boundary

reviewer-iter5 (independent cross-model) found two HIGH residuals in the iter5
allow-list hardening. Both are closed here.

G15 (security) — the canonicalizer only resolved reparse points for an EXISTING
leaf (CreateFileW/OPEN_EXISTING). A not-yet-created XAML leaf under an in-root
junction (e.g. C:\trusted\link\Page.xaml, no file) failed that call and fell back
to the lexical path, which still passed lexical containment. XamlProjectResolver
.FindOwningProject only needs the DIRECTORY (new FileInfo(path).Directory +
dir.GetFiles("*.csproj")), so the external junction target's .csproj was still
MSBuild-evaluated — a code-exec bypass. CanonicalizePath now walks up to the
deepest EXISTING ancestor (which follows any junction/symlink in the ancestry),
resolves it via GetFinalPathNameByHandle, and re-appends the missing tail; lexical
fallback is used only when no ancestor exists or the OS call fails. Legit
not-yet-saved files under a real (non-junction) dir still resolve in-root, so
IntelliSense for new files is preserved.

G12 (test-coverage) — the junction regression test never created the linked file,
so it exercised the same missing-leaf fallback, and its Assert.False threw inside a
broad catch(Exception) that swallowed the failure: a green test over a live hole.
The test now creates the linked file and asserts BOTH the existing-leaf and the
missing-leaf paths canonicalize OUTSIDE the trusted root, with the assertions moved
out of the swallowing catch (it skips only on genuine junction-creation failure).
The out-of-root lsp-smoke case gains a sibling .csproj + code-behind so the null F12
result is attributable to the trust gate rather than to an absent project.

Verification: build:server:debug 0 warnings/0 errors; test:server 505/505 (junction
test genuinely exercised — junctions confirmed creatable in this env);
test:xaml-smoke PASS incl. the strengthened out-of-root case; full integration
1605 passing / 0 failing / 28 pending.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03c713cc-41ae-4c28-ab49-3c851aa8e2bd
Comment thread test/xaml-integration/redteam-gap4.test.js Fixed
Comment thread test/xaml-integration/redteam19.test.js Fixed
nmetulev and others added 2 commits July 13, 2026 19:03
- test:xaml-smoke now runs restore:fixture before the smoke harness so
  element completion has WinUI SDK metadata in CI. The fixture's obj/ is
  gitignored and was previously only restored by the integration pretest,
  so the standalone smoke step failed in CI with 0 completion items. This
  unblocks build-and-test and the packaged VSIX artifact.
- redteam-gap4.test.js: use replaceAll for the caret-marker strip
  (CodeQL "incomplete string escaping or encoding").
- redteam19.test.js: drop the no-op self-replacement
  (CodeQL "replacement of a substring with itself").
- pr-description.yml: tolerate the REST diff API's 20k-line cap (HTTP 406)
  on very large PRs instead of failing; fall back to the changed-file list.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03c713cc-41ae-4c28-ab49-3c851aa8e2bd
CodeQL's full scan of the migrated test files (features.test.js, smoke.mjs)
flagged sanitization patterns in test helpers. All fixes are behavior
preserving (smoke PASS; integration 1605 passing / 0 failing):

- caret-marker strip: .replace("|", "") -> .replaceAll("|", "")
  (js/incomplete-sanitization).
- exact completion-label membership: array.includes(<uri>) ->
  array.some((l) => l === <uri>) — these were array-membership checks that
  CodeQL misread as URL substring checks (js/incomplete-url-substring-sanitization).
- diagnostic display string: drop the nested template literal in favor of
  string concatenation (js/bad-code-sanitization).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 03c713cc-41ae-4c28-ab49-3c851aa8e2bd
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.

2 participants