From e5bc106dbab544e68124fc6086fd6e3be03e4e91 Mon Sep 17 00:00:00 2001 From: Clint Rutkas Date: Mon, 27 Jul 2026 22:24:42 -0700 Subject: [PATCH] Replace Calm OS DSC with Slipstream bootstrap Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bfb5df03-5ec9-441c-8380-270588a8138a --- .github/workflows/ci.yml | 12 + .pipelines/OneBranch.SignAndPackage.yml | 7 +- README.md | 28 +- src/docs/development.md | 50 +- .../QuickWingetSetup/Models/ScriptEntry.cs | 12 + .../Pages/QuickWingetSetupPage.cs | 16 +- .../Pages/ScriptDetailPage.cs | 36 +- .../Services/ScriptFetchService.cs | 151 +++ .../Services/ScriptRunnerService.cs | 32 + src/future/cmdpal/README.md | 24 +- src/manifest.yml | 23 +- src/tests/slipstream/validate.ps1 | 66 ++ src/windows-dev-config/README.md | 373 ++---- src/windows-dev-config/bootstrap/common.ps1 | 462 ++++++++ .../bootstrap/configure.ps1 | 460 +++++++ .../bootstrap/controller.ps1 | 236 ++++ src/windows-dev-config/bootstrap/platform.ps1 | 441 +++++++ src/windows-dev-config/bootstrap/resume.ps1 | 231 ++++ src/windows-dev-config/bootstrap/user.ps1 | 80 ++ src/windows-dev-config/bootstrap/verify.ps1 | 142 +++ src/windows-dev-config/config/packages.json | 95 ++ src/windows-dev-config/config/registry.json | 187 +++ src/windows-dev-config/dev-config.winget | 1056 ----------------- src/windows-dev-config/install.ps1 | 552 ++++++++- 24 files changed, 3372 insertions(+), 1400 deletions(-) create mode 100644 src/tests/slipstream/validate.ps1 create mode 100644 src/windows-dev-config/bootstrap/common.ps1 create mode 100644 src/windows-dev-config/bootstrap/configure.ps1 create mode 100644 src/windows-dev-config/bootstrap/controller.ps1 create mode 100644 src/windows-dev-config/bootstrap/platform.ps1 create mode 100644 src/windows-dev-config/bootstrap/resume.ps1 create mode 100644 src/windows-dev-config/bootstrap/user.ps1 create mode 100644 src/windows-dev-config/bootstrap/verify.ps1 create mode 100644 src/windows-dev-config/config/packages.json create mode 100644 src/windows-dev-config/config/registry.json delete mode 100644 src/windows-dev-config/dev-config.winget diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 543bf46..36f7df8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -214,6 +214,18 @@ jobs: if-no-files-found: ignore retention-days: 14 + slipstream-static: + name: win / slipstream-static + runs-on: windows-latest + timeout-minutes: 5 + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Validate PowerShell-only Calm OS payload + shell: powershell + run: ./src/tests/slipstream/validate.ps1 + # --------------------------------------------------------------------------- # Job C: Linux parity. Wired up so future milestones only need manifest + scripts. # --------------------------------------------------------------------------- diff --git a/.pipelines/OneBranch.SignAndPackage.yml b/.pipelines/OneBranch.SignAndPackage.yml index 073cb03..b35683e 100644 --- a/.pipelines/OneBranch.SignAndPackage.yml +++ b/.pipelines/OneBranch.SignAndPackage.yml @@ -15,9 +15,10 @@ resources: ref: refs/heads/main extends: - template: v2/OneBranch.Official.CrossPlat.yml@templates - # Use the non-official template for testing changes in non-production branches - # template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates + # Slipstream preview: use non-official governance while validating signed + # artifacts from the development branch. Switch back to Official before the + # production release path is merged. + template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates parameters: stages: - stage: release diff --git a/README.md b/README.md index 249d4ad..049246e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Three developer setups live in this repo. Pick the one that matches what you wan | A polished WSL shell: zsh/bash, Starship, CLI tools, and a themed terminal profile. Interactive or unattended. | [WSL Comfort](#-wsl-comfort) | | A single language toolchain: Node, Python, SQL, PowerShell, .NET, Rust, Go, Java, PHP, WinForms, or WinUI 3. One command each. | [Workloads](#-single-language-workloads) | -Most of them use [`winget configure`](https://learn.microsoft.com/en-us/windows/package-manager/winget/configure). If you've never used it before, enable it once: +The single-language workloads use [`winget configure`](https://learn.microsoft.com/en-us/windows/package-manager/winget/configure). If you've never used one before, enable it once: ```powershell winget configure --enable @@ -57,7 +57,10 @@ If that fails or `winget configure` is still not recognized, see [Troubleshootin *Turns a fresh Windows 11 box into a clean, distraction-free dev workstation in one shot.* -A single [winget configuration](https://learn.microsoft.com/en-us/windows/package-manager/configuration/) file that installs dev tools, applies opinionated Windows settings, and bootstraps WSL + Ubuntu through the required reboot. Non-interactive. Idempotent. Safe to re-run on an existing machine. +The Slipstream installer repairs its own prerequisites, installs dev tools, +applies opinionated Windows settings, and bootstraps WSL + Ubuntu through the +required reboot. It uses direct, checkpointed PowerShell rather than DSC so it +can retain elevation and resume reliably. First, get the files onto the box. The config is invoked from a local path, but the bootstrap itself is what installs Git — so on a clean Windows install you'll typically download the repo as a ZIP. If Git is already there, clone it: @@ -72,13 +75,15 @@ Expand-Archive .\WindowsDeveloperConfig.zip -DestinationPath . cd .\WindowsDeveloperConfig-main ``` -Then apply the configuration: +Then run the installer: ```powershell -winget configure -f .\windows-dev-config\dev-config.winget --accept-configuration-agreements --disable-interactivity +.\windows-dev-config\install.ps1 ``` -> ⚠️ **May reboot.** Enabling WSL needs a Windows optional feature that requires a restart. A `RunOnce` task picks the configuration back up after you sign in, installs Ubuntu, and finishes the run. Expect one hard reboot plus about a minute of post-login work. Save your work first. +> ⚠️ **May reboot.** Enabling WSL needs a Windows optional feature that requires +> a restart. An elevated interactive-user scheduled task resumes setup after +> sign-in without another UAC prompt. Save your work first.
What you get @@ -86,7 +91,7 @@ winget configure -f .\windows-dev-config\dev-config.winget --accept-configuratio - **Dev tools:** PowerShell 7, Git, GitHub CLI, VS Code, .NET SDK 10, Python 3.14 + uv, Node.js, Coreutils for Windows, Oh My Posh, and PowerToys. - **Terminal:** PowerShell 7 is the default profile, Oh My Posh is enabled, and Cascadia Mono NF is set as the default font. - **Windows settings:** Dark theme, developer mode, long paths, File Explorer defaults, Start/Search cleanup, Edge policies, and other workstation defaults. -- **WSL:** WSL platform + Ubuntu, including the reboot and the `RunOnce` resume step. +- **WSL:** WSL platform + Ubuntu, including reboot-safe elevated scheduled-task resume.
@@ -164,7 +169,11 @@ See [`src/future/cmdpal/README.md`](./src/future/cmdpal/README.md) for build and
"Unrecognized command: configure" -Run `winget configure --enable`. If `winget configure` is still not recognized after that, [`Workloads/_common/assert-winget-configure.ps1`](./Workloads/_common/assert-winget-configure.ps1) tells you whether App Installer is too old, policy has disabled configuration, or something else needs fixing. +This only affects the single-language workloads. Run `winget configure --enable`. +If it is still not recognized, [`Workloads/_common/assert-winget-configure.ps1`](./Workloads/_common/assert-winget-configure.ps1) +identifies whether App Installer is stale or policy has disabled configuration. +The full Windows Dev Config repairs direct WinGet prerequisites itself and does +not use `winget configure`.
@@ -195,7 +204,10 @@ Open a new terminal, or run the matching `install.ps1` shim to refresh PATH in t
Windows Dev Config rebooted the machine and looks stuck -It registered a `RunOnce` entry, so `winget configure` resumes once you sign back in. Give it a minute after login. +It registered an elevated scheduled task for the initiating user. Sign back in +and give the setup window a minute to appear. Run +`.\windows-dev-config\install.ps1 -Action Status` to see its checkpoint and log +location.
diff --git a/src/docs/development.md b/src/docs/development.md index b408197..5cbc4e0 100644 --- a/src/docs/development.md +++ b/src/docs/development.md @@ -8,16 +8,12 @@ Opinionated, CI-validated configurations for bootstrapping developer toolchains and Windows-desktop personalities using `winget` / `winget configure`. -On Windows the **core artifact of each flow is a [winget DSC configuration -file](https://learn.microsoft.com/windows/package-manager/configuration/)** -(`configuration.winget` for language toolchains, `dev-config.winget` for the -Calm OS flow) — a declarative, idempotent description of the machine state -required for that flow. Where winget alone is not enough (e.g. `npm install ---global typescript`, registry tweaks, or a `RunOnce` reboot dance) the -configuration calls a DSC `Script` / `RunCommandOnSet` / `Registry` -resource, so everything the flow needs lives in one YAML file. A small -`install.ps1` shim next to it applies the config with `winget configure` -and handles session-level glue (PATH refresh, CI sentinel). +On Windows, single-language flows use a `configuration.winget` DSC document plus +a thin `install.ps1` shim. Calm OS is the exception: its Slipstream installer is +a checkpointed PowerShell payload with declarative JSON package and registry +manifests. It owns elevation, prerequisite repair, WSL restarts, resume, and +verification directly because those lifecycle concerns do not fit reliably +inside a single DSC invocation. Every flow is **exercised on a real GitHub-hosted runner** on every push, pull request, and nightly: the DSC config is applied, then a canonical "hello @@ -27,8 +23,8 @@ configuration actually produced a working toolchain. ## Supported flows -Each flow's `configuration.winget` (or `dev-config.winget` for Calm OS) -is the source of truth for what gets installed; the table below +Each flow's configuration artifact is the source of truth for what gets +installed; the table below summarizes it for quick scanning. Flows marked **manual** are excluded from the automated CI matrix (they need an interactive desktop session or pull multi-GB workloads we don't want to chew minutes on), but are @@ -48,7 +44,7 @@ extension. | PowerShell | ✅ automated | `Microsoft.PowerShell`, `Microsoft.VisualStudioCode`, VS Code PowerShell/Pester extensions + PSScriptAnalyzer settings | | WinForms | 🙋 manual | `Microsoft.DotNet.SDK.10` + the .NET desktop workload (multi-GB; manual to spare CI minutes) | | WinUI 3 | 🙋 manual | `Microsoft.DotNet.SDK.10`, `Microsoft.VisualStudio.Community`, `Microsoft.WinAppCLI` + WinUI/Universal/ManagedDesktop VS workloads | -| Calm OS | 🙋 manual | A full distraction-free workstation: apps + ~24 registry tweaks + WSL + Ubuntu (see [`windows-dev-config/README.md`](../windows-dev-config/README.md)) | +| Calm OS | 🙋 manual | PowerShell Slipstream: apps + registry settings + WSL + Ubuntu with one-UAC reboot resume (see [`windows-dev-config/README.md`](../windows-dev-config/README.md)) | | Comfort Shell | 🙋 manual | WSL distro + zsh/bash + starship + modern CLI bundle + Cascadia Code Nerd Font + themed Windows Terminal profile (see [`wsl-comfort/readme.md`](../wsl-comfort/readme.md)) | See [`manifest.yml`](../manifest.yml) for the canonical declarative @@ -80,7 +76,7 @@ Workloads/ rust/ # configuration.winget (core) + install.ps1 (thin shim) winforms/ # configuration.winget (core) + install.ps1 (thin shim) winui/ # configuration.winget (core) + install.ps1 (thin shim) -windows-dev-config/ # Calm OS — dev-config.winget (single-file DSC) + install.ps1 + README.md +windows-dev-config/ # Calm OS — signed Slipstream scripts + JSON manifests + README.md wsl-comfort/ # Comfort Shell — install.ps1 (Windows side) + comfort-shell-bootstrap.sh (Linux side, self-contained) + readme.md tests/ _harness/ # build-run-diff harness used by CI: @@ -122,7 +118,11 @@ This repo carries **two parallel copies** of every flow: | `src/docs/development.md` | Contributor docs (CI, validation, how to add a language). | **Yes** | n/a | | `src/tests/` | Hello-world programs + expected stdout used by the CI harness. | **Yes** | CI only | -**End users**: the commands in the top-level [README](../../README.md) point at the **top-level signed copies** on purpose. If you're following the README on a Windows box you don't need to know `src/` exists. Every `winget configure -f .\windows-dev-config\dev-config.winget`-style invocation in the README is correct as written. +**End users**: the commands in the top-level [README](../../README.md) point at +the **top-level signed copies** on purpose. If you're following the README on a +Windows box you don't need to know `src/` exists. Calm OS runs its signed +`windows-dev-config\install.ps1`; standalone workloads keep their +`winget configure` commands. **Contributors**: edit `src/`. The top-level paths are **regenerated** by [`.pipelines/OneBranch.SignAndPackage.yml`](../../.pipelines/OneBranch.SignAndPackage.yml), which Authenticode-signs every `src/**/*.ps1` and ships them (plus the `.winget` configs and the manifest) as the release artifact. The signed copies were merged into `main` from the `signed` branch in [PR #6](https://github.com/microsoft/WindowsDeveloperConfig/pull/6). A change to a `src/` script becomes a new signed top-level copy on the next sign cycle, not at PR merge, so the two can briefly disagree on a script's body until that cycle runs. @@ -144,9 +144,8 @@ Maintainers: once this guard has landed, add **`Signed copy guard`** to the requ ## Prerequisites (Windows) -Every flow — and the [Command Palette extension](../future/cmdpal/) — installs -toolchains through `winget configure`. That subcommand must be available on -your machine before anything in this repo can succeed: +Standalone language flows install toolchains through `winget configure`. That +subcommand must be available before those flows can succeed: - **App Installer (winget)** must be current. Update from the Microsoft Store, or grab the latest MSIX from @@ -166,12 +165,15 @@ winget configure --help | Select-Object -First 3 ``` If the help text prints, you're good. If it errors or prints -"Unrecognized command", fix the above before running any flow. Each -`install.ps1` shim runs +"Unrecognized command", fix the above before running a standalone workload. +Each workload `install.ps1` shim runs [`Workloads/_common/assert-winget-configure.ps1`](../Workloads/_common/assert-winget-configure.ps1) first and will emit an actionable message describing exactly which of the three conditions above needs attention. +Calm OS does not require `winget configure`; Slipstream repairs direct WinGet +prerequisites before installing anything. + ## Running a flow locally (Windows) Apply the DSC configuration directly with winget: @@ -201,9 +203,8 @@ problems before pushing. > A clean Windows VM (e.g. a throwaway Hyper-V / Dev Box / Windows Sandbox > image) is strongly recommended for any step that actually installs -> toolchains. Applying a DSC config on your daily-driver machine will happily -> install Node, PHP, etc. system-wide — and since these flows are idempotent, -> that is generally harmless but not always what you want. +> toolchains. These flows intentionally install machine software and change +> developer settings. ### 1. Static checks (any OS, fast) @@ -226,6 +227,9 @@ Get-ChildItem -Recurse -Filter *.ps1 | ForEach-Object { $_.FullName, [ref]$null, [ref]$errs) if ($errs) { Write-Error "$($_.FullName): $errs" } else { "OK: $($_.Name)" } } + +# Slipstream payload, manifest hashes, and task definition (no mutation). +./windows-dev-config/install.ps1 -Action Validate -AllowUnsigned ``` If you have [PSScriptAnalyzer](https://github.com/PowerShell/PSScriptAnalyzer) diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Models/ScriptEntry.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Models/ScriptEntry.cs index ec559be..adaa40a 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Models/ScriptEntry.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Models/ScriptEntry.cs @@ -88,6 +88,12 @@ public string? WindowsConfigurationPath } } + public bool UsesPowerShellInstaller => + string.Equals(Windows?.Execution, "powershell", System.StringComparison.OrdinalIgnoreCase); + + public string? WindowsLaunchPath => + UsesPowerShellInstaller ? Windows?.Install : WindowsConfigurationPath; + /// WSL/Linux install script path, e.g. scripts/linux/php/install.sh. public string? LinuxInstallPath => Linux?.Install; } @@ -97,6 +103,12 @@ public class WindowsTarget [JsonPropertyName("install")] public string? Install { get; set; } + [JsonPropertyName("execution")] + public string? Execution { get; set; } + + [JsonPropertyName("payloadFiles")] + public string[]? PayloadFiles { get; set; } + [JsonPropertyName("configuration")] public string? Configuration { get; set; } diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/QuickWingetSetupPage.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/QuickWingetSetupPage.cs index deb6b7c..673e53d 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/QuickWingetSetupPage.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/QuickWingetSetupPage.cs @@ -46,14 +46,13 @@ public override IListItem[] GetItems() var health = WingetConfigureHealthService.RefreshStatus(); var items = new List(); - // Hard-fail banner if `winget configure` isn't available. Every - // Windows flow depends on this, so we put it above the flows - // list and block the "Run Windows Setup" path until it's fixed. + // DSC workloads need `winget configure`; direct PowerShell + // installers such as Calm OS can still run without it. if (health != WingetConfigureStatus.Available) { items.Add(new ListItem(new EnableWingetConfigureCommand(_fetchService, this)) { - Title = "⚠️ `winget configure` is unavailable", + Title = "⚠️ DSC workloads are unavailable", Subtitle = WingetConfigureHealthService.DescribeStatus(health) + " · Select to fix (elevates).", Tags = [new Tag("blocker")], @@ -61,12 +60,9 @@ public override IListItem[] GetItems() } items.AddRange(manifest.Flows - // CmdPal is Windows-only today (the extension itself runs - // on Windows and the launch primitive is `winget configure` - // in a wt.exe tab). Hide flows that don't declare Windows - // support so users don't pick something the extension - // can't actually launch. - .Where(s => s.WindowsConfigurationPath is not null) + // CmdPal is Windows-only today. Hide flows without either a + // DSC configuration or a direct PowerShell installer. + .Where(s => s.WindowsLaunchPath is not null) .OrderBy(s => CategoryRank(s.Category)) .ThenBy(s => s.Category) .ThenBy(s => s.Name) diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/ScriptDetailPage.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/ScriptDetailPage.cs index ed5531c..cf14dd9 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/ScriptDetailPage.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Pages/ScriptDetailPage.cs @@ -27,11 +27,15 @@ public override IListItem[] GetItems() { var items = new List(); - if (_script.WindowsConfigurationPath is { } winPath) + if (_script.WindowsLaunchPath is { } winPath) { - var localPath = _fetchService.GetScriptPathAsync(winPath).GetAwaiter().GetResult(); + var localPath = (_script.UsesPowerShellInstaller + ? _fetchService.GetPayloadEntryPathAsync(winPath, _script.Windows?.PayloadFiles) + : _fetchService.GetScriptPathAsync(winPath)).GetAwaiter().GetResult(); var winTags = new List { new("Windows") }; - var winSubtitle = $"winget configure {winPath}"; + var winSubtitle = _script.UsesPowerShellInstaller + ? $"PowerShell installer {winPath}" + : $"winget configure {winPath}"; // Flows that need WSL even though they register as Windows-only // (e.g. mac-comfort-shell installs a font via DSC but the @@ -56,7 +60,7 @@ public override IListItem[] GetItems() } } - items.Add(new ListItem(new RunWinGetCommand(winPath, _fetchService, _script)) + items.Add(new ListItem(new RunWindowsSetupCommand(winPath, _fetchService, _script)) { Title = "🪟 Run Windows Setup", Subtitle = winSubtitle, @@ -120,7 +124,7 @@ private static IContextItem[] BuildContextCommands(string? localPath) } } -internal sealed partial class RunWinGetCommand : InvokableCommand, IConfirmationArgs +internal sealed partial class RunWindowsSetupCommand : InvokableCommand, IConfirmationArgs { private const string FixItRelativePath = "scripts/windows/_common/enable-winget-configure.ps1"; @@ -128,7 +132,7 @@ internal sealed partial class RunWinGetCommand : InvokableCommand, IConfirmation private readonly ScriptFetchService _fetchService; private readonly ScriptEntry _script; - public RunWinGetCommand(string scriptPath, ScriptFetchService fetchService, ScriptEntry script) + public RunWindowsSetupCommand(string scriptPath, ScriptFetchService fetchService, ScriptEntry script) { _scriptPath = scriptPath; _fetchService = fetchService; @@ -143,7 +147,9 @@ public RunWinGetCommand(string scriptPath, ScriptFetchService fetchService, Scri // list page already telegraphs intent; the dialog is the seatbelt. public string Title => $"Run {_script.Name} setup?"; public string Description => - $"This will run `winget configure` against {_scriptPath} in a new Windows Terminal tab. " + (_script.UsesPowerShellInstaller + ? $"This will run the PowerShell installer {_scriptPath} in a new Windows Terminal tab. " + : $"This will run `winget configure` against {_scriptPath} in a new Windows Terminal tab. ") + "Some flows install packages, change Windows settings, or enable WSL. Re-running is safe (each flow is idempotent)."; public Microsoft.CommandPalette.Extensions.ICommand? PrimaryCommand => this; public bool IsPrimaryCommandCritical => false; @@ -165,8 +171,10 @@ public override ICommandResult Invoke() // page last rendered). If still broken, divert to the remediation // script instead of launching a wt.exe tab that would just fail // with an opaque winget error. - var health = WingetConfigureHealthService.RefreshStatus(); - if (health != WingetConfigureStatus.Available) + var health = _script.UsesPowerShellInstaller + ? WingetConfigureStatus.Available + : WingetConfigureHealthService.RefreshStatus(); + if (!_script.UsesPowerShellInstaller && health != WingetConfigureStatus.Available) { try { @@ -185,12 +193,20 @@ public override ICommandResult Invoke() return CommandResult.Dismiss(); } - var localPath = _fetchService.GetScriptPathAsync(_scriptPath).GetAwaiter().GetResult(); + var localPath = (_script.UsesPowerShellInstaller + ? _fetchService.GetPayloadEntryPathAsync(_scriptPath, _script.Windows?.PayloadFiles) + : _fetchService.GetScriptPathAsync(_scriptPath)).GetAwaiter().GetResult(); if (localPath == null) { return CommandResult.Dismiss(); } + if (_script.UsesPowerShellInstaller) + { + ScriptRunnerService.RunPowerShellInstaller(localPath); + return CommandResult.Dismiss(); + } + // Resolve an optional post-configure step (e.g. mac-my-wsl.ps1 // -Interactive) and chain it in the same wt tab so the user gets // one continuous experience instead of a manual follow-up. diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptFetchService.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptFetchService.cs index 35a2b20..6af1230 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptFetchService.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptFetchService.cs @@ -1,6 +1,8 @@ using System; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.IO.Compression; +using System.Linq; using System.Net.Http; using System.Text.Json; using System.Threading; @@ -148,6 +150,155 @@ private static string ConvertYamlToJson(string yaml) } } + /// + /// Returns an entry point together with its sibling payload. Remote + /// PowerShell installers can span multiple files, unlike a DSC document, + /// so cache an extracted repository snapshot instead of one raw file. + /// + public async Task GetPayloadEntryPathAsync( + string relativePath, + string[]? requiredPayloadFiles = null) + { + if (relativePath.Contains("..") || Path.IsPathRooted(relativePath)) + return null; + + if (_config.Source == "local") + { + var localEntry = await GetScriptPathAsync(relativePath); + return HasRequiredPayloadFiles(localEntry, requiredPayloadFiles) ? localEntry : null; + } + + var cacheDir = GetRepositoryCacheDirectory(); + var expected = Path.Combine(cacheDir, relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (IsPayloadCacheFresh(cacheDir, expected, requiredPayloadFiles)) + return expected; + + await _cacheLock.WaitAsync(); + try + { + if (IsPayloadCacheFresh(cacheDir, expected, requiredPayloadFiles)) + return expected; + + if (Directory.Exists(cacheDir)) + Directory.Delete(cacheDir, true); + Directory.CreateDirectory(cacheDir); + + var archiveUrl = + $"https://github.com/{_config.GithubRepo}/archive/refs/heads/{_config.GithubBranch}.zip"; + var archivePath = Path.Combine(cacheDir, "repository.zip"); + await using (var source = await _httpClient.GetStreamAsync(archiveUrl)) + await using (var destination = File.Create(archivePath)) + { + await source.CopyToAsync(destination); + } + + var extractRoot = Path.Combine(cacheDir, "extract"); + ZipFile.ExtractToDirectory(archivePath, extractRoot); + File.Delete(archivePath); + var repositoryRoot = Directory.GetDirectories(extractRoot).FirstOrDefault(); + if (repositoryRoot == null) + return null; + + var extractedEntry = Path.Combine( + repositoryRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar)); + if (!File.Exists(extractedEntry)) + return null; + + var payloadDirectory = Path.GetDirectoryName(expected); + if (payloadDirectory == null) + return null; + Directory.CreateDirectory(payloadDirectory); + + var sourceDirectory = Path.GetDirectoryName(extractedEntry); + if (sourceDirectory == null) + return null; + foreach (var sourcePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(sourceDirectory, sourcePath); + var destinationPath = Path.Combine(payloadDirectory, relative); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + File.Copy(sourcePath, destinationPath, true); + } + if (!HasRequiredPayloadFiles(expected, requiredPayloadFiles)) + return null; + + File.WriteAllText( + Path.Combine(cacheDir, ".fetched-at"), + DateTime.UtcNow.ToString("O")); + return expected; + } + catch + { + return null; + } + finally + { + _cacheLock.Release(); + } + } + + private string GetRepositoryCacheDirectory() + { + var invalid = Path.GetInvalidFileNameChars(); + var safeRepo = string.Join("_", _config.GithubRepo.Split(invalid)); + var safeBranch = string.Join("_", _config.GithubBranch.Split(invalid)); + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "QuickWingetSetup", "cache", "repositories", safeRepo, safeBranch); + } + + private bool IsPayloadCacheFresh( + string cacheDirectory, + string entryPath, + string[]? requiredPayloadFiles) + { + if (!HasRequiredPayloadFiles(entryPath, requiredPayloadFiles)) + return false; + + var stampPath = Path.Combine(cacheDirectory, ".fetched-at"); + if (!File.Exists(stampPath)) + return false; + + var ttl = TimeSpan.FromDays(Math.Max(0, _config.CacheTTLDays)); + return ttl > TimeSpan.Zero + && DateTime.UtcNow - File.GetLastWriteTimeUtc(stampPath) < ttl; + } + + private static bool HasRequiredPayloadFiles(string? entryPath, string[]? requiredPayloadFiles) + { + if (string.IsNullOrEmpty(entryPath) || !File.Exists(entryPath)) + return false; + if (requiredPayloadFiles == null || requiredPayloadFiles.Length == 0) + return true; + + var payloadRoot = Path.GetDirectoryName(entryPath); + if (payloadRoot == null) + return false; + var trustedRoot = Path.GetFullPath(payloadRoot).TrimEnd(Path.DirectorySeparatorChar) + + Path.DirectorySeparatorChar; + + foreach (var relativePath in requiredPayloadFiles) + { + if (string.IsNullOrWhiteSpace(relativePath) + || relativePath.Contains("..") + || Path.IsPathRooted(relativePath)) + { + return false; + } + + var candidate = Path.GetFullPath(Path.Combine( + payloadRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!candidate.StartsWith(trustedRoot, StringComparison.OrdinalIgnoreCase) + || !File.Exists(candidate)) + { + return false; + } + } + return true; + } + public Task ForceRefreshAsync() { _cachedManifest = null; diff --git a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptRunnerService.cs b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptRunnerService.cs index 678444c..260d8cb 100644 --- a/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptRunnerService.cs +++ b/src/future/cmdpal/QuickWingetSetup/QuickWingetSetup/Services/ScriptRunnerService.cs @@ -7,6 +7,38 @@ namespace QuickWingetSetup.Services; public static class ScriptRunnerService { + public static void RunPowerShellInstaller(string scriptPath) + { + if (!File.Exists(scriptPath)) + { + throw new FileNotFoundException("PowerShell installer not found.", scriptPath); + } + + var signatureCommand = "$signature=Get-AuthenticodeSignature -LiteralPath '" + + EscapeSingleQuotes(scriptPath) + + "'; if($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation'){" + + "throw \"Invalid Microsoft signature: $($signature.Status) $($signature.SignerCertificate.Subject)\"}; "; + var sanitizedPath = scriptPath.Replace("\"", ""); + var command = "$ErrorActionPreference='Stop'; " + + signatureCommand + + "& '" + + EscapeSingleQuotes(sanitizedPath) + + "'"; + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(command)); + var shell = ResolveShell(); + var psi = new ProcessStartInfo + { + FileName = "wt.exe", + Arguments = $"new-tab -- \"{shell}\" -NoExit -NoProfile -ExecutionPolicy AllSigned -EncodedCommand {encoded}", + UseShellExecute = true, + }; + var process = Process.Start(psi); + if (process == null) + { + throw new InvalidOperationException("Failed to launch Windows Terminal. Ensure wt.exe is available."); + } + } + public static void RunWinGetConfig(string scriptPath) { RunWinGetConfig(scriptPath, postConfigureScriptPath: null, postConfigureArgs: null); diff --git a/src/future/cmdpal/README.md b/src/future/cmdpal/README.md index d1111d6..3b56187 100644 --- a/src/future/cmdpal/README.md +++ b/src/future/cmdpal/README.md @@ -3,15 +3,13 @@ A [PowerToys Command Palette](https://learn.microsoft.com/windows/powertoys/command-palette/overview) extension that surfaces the developer flows defined in this repo's [`manifest.yml`](../../manifest.yml). Pick a flow, hit Enter, and the extension -launches `winget configure` (Windows) or `wsl bash` (Linux) in a new Windows -Terminal tab — no need to remember which `.winget` file goes with which -toolchain. +launches its `winget configure` document or signed PowerShell payload in a new +Windows Terminal tab. -## Prerequisite: `winget configure` must be enabled +## Prerequisites -This extension launches flows exclusively through `winget configure`. If -that subcommand is not wired up on the host, no Windows flow surfaced by -CmdPal can succeed. See the developer guide's +Standalone workloads launch through `winget configure`. If that subcommand is +not wired up on the host, those flows cannot succeed. See the developer guide's [`Prerequisites (Windows)`](../../docs/development.md#prerequisites-windows) section for the three conditions that must hold (current App Installer, the `configuration` feature enabled, and no blocking ADMX policy) and the @@ -19,13 +17,17 @@ one-line smoke test. The shared preflight [`Workloads/_common/assert-winget-configure.ps1`](../../Workloads/_common/assert-winget-configure.ps1) enforces this at runtime with an actionable error message. +Calm OS launches its signed, multi-file Slipstream PowerShell payload instead. +The extension requires every `windows.payloadFiles` entry to be present and +verifies the Microsoft Authenticode signature on the entry point. Slipstream +then validates every sibling script and manifest before requesting elevation. + ## Source of truth The extension reads the same `manifest.yml` that drives CI. Each flow's UX metadata (`name`, `description`, `category`, `tags`, `icon`, `onboardingUrl`, -`dependsOn`) plus its `windows.configuration` / `linux.install` paths come -straight from that file — adding a flow there makes it appear in CmdPal -automatically. +`dependsOn`) plus its `windows.configuration`, `windows.install`, and +`windows.execution` fields come straight from that file. ## Categories and ordering @@ -92,6 +94,8 @@ The project targets `net9.0-windows10.0.26100.0` and is AOT/trim friendly. | Manifest field | What the extension does | | ----------------------------------- | ----------------------------------------------------------- | | `windows.configuration` | `winget configure ` in a new Windows Terminal tab, after a confirmation dialog | +| `windows.execution: powershell` | Runs the signed `windows.install` payload directly | +| `windows.payloadFiles` | Requires every listed sibling before enabling a multi-file PowerShell payload | | `onboardingUrl` | Opens in the default browser via `📖 Official Docs` action | | `icon`, `name`, `description`, ... | Rendered on the list/detail pages | diff --git a/src/manifest.yml b/src/manifest.yml index d7631c9..aaca7e8 100644 --- a/src/manifest.yml +++ b/src/manifest.yml @@ -59,8 +59,13 @@ # install: path to PowerShell install shim # CI runs this from the `src/` directory, while CmdPal # assumes the extension's root is the repo root. -# configuration: (optional) path to winget DSC configuration.winget the -# extension applies via `winget configure`. Defaults to +# execution: (optional) "powershell" for a direct PowerShell installer; +# defaults to "wingetConfiguration". +# payloadFiles: (optional) paths relative to the install script's directory +# that must be present before CmdPal enables a multi-file +# PowerShell payload. +# configuration: path to the winget DSC configuration when execution is +# "wingetConfiguration". Defaults to # "/configuration.winget" when omitted. # build: shell command to build the hello world (run from repo # root). "" to skip. @@ -279,7 +284,7 @@ flows: - id: calm-os name: Calm OS - description: Distraction-free dev workstation — apps + OS settings + WSL, all in one DSC + description: Distraction-free dev workstation — apps + OS settings + WSL with reboot-safe resume category: user-experience tags: [user-experience, calm-os, distraction-free, taskbar, wsl, ubuntu] icon: 🧘 @@ -293,7 +298,17 @@ flows: os: [windows] windows: install: windows-dev-config/install.ps1 - configuration: windows-dev-config/dev-config.winget + execution: powershell + payloadFiles: + - bootstrap/common.ps1 + - bootstrap/controller.ps1 + - bootstrap/platform.ps1 + - bootstrap/resume.ps1 + - bootstrap/configure.ps1 + - bootstrap/user.ps1 + - bootstrap/verify.ps1 + - config/packages.json + - config/registry.json build: "" run: pwsh -NoProfile -File src/tests/calm-os/probe.ps1 expected: src/tests/calm-os/expected.txt diff --git a/src/tests/slipstream/validate.ps1 b/src/tests/slipstream/validate.ps1 new file mode 100644 index 0000000..4b6810e --- /dev/null +++ b/src/tests/slipstream/validate.ps1 @@ -0,0 +1,66 @@ +<# +.SYNOPSIS + Non-destructive validation for the Calm OS Slipstream payload. +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$srcRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$payloadRoot = Join-Path $srcRoot 'windows-dev-config' +$installPath = Join-Path $payloadRoot 'install.ps1' +$validationRoot = Join-Path $env:TEMP "WindowsDeveloperConfig-Validation-$([guid]::NewGuid())" + +try { + $summary = & $installPath -Action Validate -AllowUnsigned + if ($summary.Status -ne 'Valid') { + throw "Unexpected validation status: $($summary.Status)" + } + if ($summary.Scripts -ne 8 -or + $summary.Packages -ne 15 -or + $summary.RegistryValues -ne 26) { + throw "Unexpected payload counts: scripts=$($summary.Scripts), packages=$($summary.Packages), registry=$($summary.RegistryValues)" + } + if ($summary.TaskLogonType -ne 'Interactive' -or $summary.TaskRunLevel -ne 'Highest') { + throw "Unsafe resume principal: $($summary.TaskLogonType) / $($summary.TaskRunLevel)" + } + if ($summary.UserTaskLogonType -ne 'Interactive' -or + $summary.UserTaskRunLevel -ne 'Limited') { + throw "Unsafe user-task principal: $($summary.UserTaskLogonType) / $($summary.UserTaskRunLevel)" + } + + . (Join-Path $payloadRoot 'bootstrap\common.ps1') + $script:SlipstreamProgramDataRoot = $validationRoot + $state = [pscustomobject][ordered]@{ + schemaVersion = 1 + runId = [guid]::NewGuid().ToString() + status = 'Running' + phase = 'Preflight' + updatedAtUtc = [DateTime]::UtcNow.ToString('o') + } + Save-SlipstreamState -State $state + $state.phase = 'RepairPlatform' + Save-SlipstreamState -State $state + $roundTrip = Read-SlipstreamState -RunId $state.runId + if ($roundTrip.phase -ne 'RepairPlatform') { + throw 'Atomic state round-trip did not retain the latest phase.' + } + + $runtimeScripts = Get-ChildItem ` + -LiteralPath $payloadRoot ` + -Recurse ` + -Filter *.ps1 ` + -File + $dscReferences = $runtimeScripts | + Select-String -Pattern '\bwinget\s+configure\b|\bdsc\.exe\b' + if ($dscReferences) { + throw "Slipstream runtime still invokes DSC: $($dscReferences.Path -join ', ')" + } + + Write-Host 'SLIPSTREAM_VALIDATION_OK' +} +finally { + if (Test-Path -LiteralPath $validationRoot) { + Remove-Item -LiteralPath $validationRoot -Recurse -Force + } +} diff --git a/src/windows-dev-config/README.md b/src/windows-dev-config/README.md index b475d35..c338612 100644 --- a/src/windows-dev-config/README.md +++ b/src/windows-dev-config/README.md @@ -1,282 +1,139 @@ -# Dev Configuration - -A WinGet Configuration (DSC) file that sets up a clean, lightweight, distraction-free developer workstation. The goal is a PC state that devs actually love using: no clutter, no noise, just the tools you need. - -This mirrors the curated environment currently provided by Cloud PC, so developers get a consistent experience regardless of device. - -The flow is a single DSC document (`dev-config.winget`) that handles everything end-to-end: elevation, the OS tweaks, the apps, the fonts, the shell prompt, and the WSL platform + Ubuntu install (including the reboot dance). - -> **Author:** Hamza Usmani. - -## Table of Contents - -- [Goals](#goals) -- [Prerequisites](#prerequisites) -- [Usage](#usage) -- [What this configures](#what-this-configures) -- [Configuration details](#configuration-details) - - [Phase resources (elevation + WSL)](#phase-resources-elevation--wsl) - - [Apps](#apps) - - [Theme and OS](#theme-and-os) - - [File Explorer](#file-explorer) - - [Taskbar](#taskbar) - - [Start, Search, Notifications](#start-search-notifications) - - [Services and features](#services-and-features) - - [Edge](#edge) - - [Fonts](#fonts) - - [Windows Terminal](#windows-terminal) - - [PowerShell profile](#powershell-profile) -- [Customization](#customization) -- [Design decisions](#design-decisions) -- [Known caveats](#known-caveats) - ---- - -## Goals - -- **A PC devs actually want to use.** Clean Explorer, dark theme, no pop-ups, no recommendations, no widgets. Just your code and your tools. -- **Cloud PC parity.** Same tooling, OS settings, and policies as the current Cloud PC image. -- **One command.** `winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity` takes a fresh Windows machine to fully ready, including WSL + Ubuntu (with an auto-resume across the required reboot). -- **Idempotent.** Safe to re-run on existing machines to apply updates or fix drift. Every resource has a `testScript` or DSC-native idempotency. - -## Prerequisites - -- Windows 11 (latest). -- `winget` with the DSC v3 processor available (the file uses `Microsoft.WinGet/Package`, `Microsoft.Windows/Registry`, and `Microsoft.DSC.Transitional/*`). -- Administrator rights — the `ElevationCheck` resource will auto-relaunch winget elevated via `Start-Process -Verb RunAs` if you started in an unelevated session, but you'll need to consent at the UAC prompt. -- The Microsoft Visual C++ Redistributable when invoking `winget` from a non-elevated environment. Without it, `winget configure` fails with an internal error. See [aka.ms/vcredist](https://aka.ms/vcredist) or install via winget (see the Usage callout below). -- The repo on disk. `winget configure` reads a local file path, and the bootstrap is what installs Git, so on a fresh machine you'll either `git clone` (if Git is already installed) or download the repo as a ZIP from GitHub and extract it before running. -- **Hardware virtualization must be available to the OS** before WSL can install. On bare metal, this means virtualization (VT-x / AMD-V) is enabled in BIOS/UEFI. Inside a VM, it means the host has exposed nested virtualization to the guest. See the Usage callout below. - -## Usage - -> [!IMPORTANT] -> If `winget` is being invoked from a **non-elevated** environment, the Microsoft Visual C++ Redistributable ([aka.ms/vcredist](https://aka.ms/vcredist)) must also be installed — without it `winget configure` fails with an internal error. Install it once with the command for your machine's architecture: -> -> ```powershell -> # x64: -> winget install Microsoft.VCRedist.2015+.x64 -> -> # ARM64: -> winget install Microsoft.VCRedist.2015+.arm64 -> ``` - -> [!IMPORTANT] -> **WSL needs hardware virtualization.** If virtualization isn't available to the OS, the `InstallUbuntu` step fails with `wsl --install ... failed with exit code -1`. -> -> - **On bare metal:** enable virtualization (VT-x / AMD-V) in your BIOS/UEFI. The exact label varies by vendor — check your motherboard or laptop manufacturer's documentation if you can't find it. Reboot into firmware settings, toggle it on, save, and reboot back into Windows. -> - **Inside a VM:** the host must expose nested virtualization to the guest. For a Hyper-V host, run this from an elevated PowerShell session **on the host** (with the guest VM powered off): -> -> ```powershell -> Set-VMProcessor -VMName -ExposeVirtualizationExtensions $true -> ``` -> -> Other hypervisors have their own equivalent settings — check your hypervisor's documentation. - -**Get the files first** (skip if you already have the repo locally): +# Windows Developer Config: Slipstream Preview + +Slipstream turns a fresh Windows 11 machine into a developer workstation with +one command, one UAC prompt, and automatic resume after required restarts. + +This preview intentionally does **not** use `winget configure` or DSC. PowerShell +owns elevation, prerequisite repair, restart checkpoints, package installation, +settings convergence, and final verification directly. + +## Run the signed build + +Download and extract the artifact produced by the repository's OneBranch sign +pipeline, then run: + +```powershell +.\src\windows-dev-config\install.ps1 +``` + +The artifact scripts are Authenticode-signed. Slipstream rejects unsigned or +unexpectedly signed scripts before privileged configuration begins. + +What to expect: + +1. One UAC prompt. +2. A prerequisite pass that repairs WinGet when necessary. +3. An automatic restart when WSL or pending Windows servicing requires it. +4. Setup resumes elevated after sign-in without another UAC prompt. +5. Ubuntu, developer tools, Windows settings, fonts, Terminal, and shell tooling + are configured and independently verified. + +Save open work before starting. The normal clean-machine path restarts once. + +## Source-tree validation + +The unsigned `src` tree is for development only: ```powershell -# Git already installed: -git clone https://github.com/microsoft/WindowsDeveloperConfig.git -cd WindowsDeveloperConfig\windows-dev-config - -# Otherwise, download and extract the ZIP: -Invoke-WebRequest -Uri https://github.com/microsoft/WindowsDeveloperConfig/archive/refs/heads/main.zip -OutFile WindowsDeveloperConfig.zip -Expand-Archive .\WindowsDeveloperConfig.zip -DestinationPath . -cd .\WindowsDeveloperConfig-main\windows-dev-config +# Does not elevate, install, register a task, or restart. +.\src\windows-dev-config\install.ps1 -Action Validate -AllowUnsigned + +# Disposable VM only: run the unsigned source end-to-end. +.\src\windows-dev-config\install.ps1 -AllowUnsigned ``` -**Full setup (recommended):** +Use `-NoRestart` to stop at a restart checkpoint and reboot manually: ```powershell -winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity +.\src\windows-dev-config\install.ps1 -AllowUnsigned -NoRestart ``` -This is the canonical invocation documented in the header of `dev-config.winget`. +## Status and recovery -**What to expect:** +```powershell +.\src\windows-dev-config\install.ps1 -Action Status +``` -1. The first phase applies all OS tweaks, installs apps, installs Cascadia Code/Mono Nerd Fonts, and configures Windows Terminal and the PowerShell profile. -2. WSL platform components install; the DSC reboots the machine and registers a `RunOnce` resume. -3. After login, winget configure resumes automatically and installs the default Ubuntu distro. -4. Open Ubuntu from the Start menu to complete its first-launch setup (create a UNIX username and password). - -The configuration is idempotent, so it is safe to re-run after reboot or at any later point. - -## What this configures +Durable state and logs live at: -- **14 apps** via winget (PowerShell 7, Git, GitHub CLI, GitHub Copilot CLI, VS Code, .NET SDK 10, Python 3.14, UV, Node.js LTS, NVM for Windows, Coreutils for Windows, Windows Application CLI, plus optional Oh My Posh and PowerToys). -- **WSL + Ubuntu**, installed via 3 transitional script resources that bracket a reboot (Phase 2/3/4 below). -- **~24 registry settings** for theme/OS, Explorer, Taskbar, Search, Start, Notifications, Edge, Sudo, and the Widget service. -- **Cascadia Code & Cascadia Mono Nerd Fonts** downloaded from the `microsoft/cascadia-code` GitHub release and registered per-user. -- **5 script resources** beyond the WSL phases: - - `ElevationCheck` — re-launches winget elevated if not already admin. - - `darkTheme` — applies the built-in `dark.theme` to switch to dark mode. - - `InstallCascadiaCodeNerdFonts` — downloads and installs the Nerd Font variants of Cascadia Code/Mono. - - `SetCascadiaNfAsDefault` — sets `Cascadia Mono NF` as the default font face in Windows Terminal's `settings.json`. - - `ps7default` — sets PowerShell 7 as Windows Terminal's default profile. - - `ohMyPoshProfileSet` — adds `oh-my-posh init pwsh | Invoke-Expression` to `$PROFILE` and dot-sources it. +```text +C:\ProgramData\Microsoft\WindowsDeveloperConfig\ + payloads\\ + runs\\ + state.json + logs\setup.log +``` ---- +The pinned payload and resume task are scoped to a unique run ID. A failed run +removes its elevated task but retains state and logs for diagnosis. -## Configuration details +To explicitly remove a failed run's pinned payload, open an elevated PowerShell: -All resources are dscv3 (`$schema: .../DSC/main/schemas/2023/08/config/document.json`, `metadata.winget.processor.identifier: dscv3`). Every resource that touches HKLM or runs elevated tools depends on `ElevationCheck`. +```powershell +.\src\windows-dev-config\install.ps1 -Action Cleanup -RunId +``` -Package resources use `Microsoft.WinGet/Package` with `source: winget` and `useLatest: true` (except `Python.Python.3.14`, `Microsoft.dotnet.SDK.10`, and `OpenJS.NodeJS.LTS`, which are pinned by id). +## What gets configured + +- Windows Terminal and PowerShell 7 +- Git, GitHub CLI, GitHub Copilot CLI, and VS Code +- .NET 10, Python 3.14, uv, Node.js LTS, and NVM for Windows +- Coreutils for Windows, Oh My Posh, Windows Application CLI, and PowerToys +- WSL platform plus Ubuntu +- Dark theme, Developer Mode, long paths, Explorer/taskbar/search cleanup, + Edge policies, Windows Sudo, Remote Desktop, and notification settings +- Cascadia Code and Cascadia Mono Nerd Fonts +- Windows Terminal defaults and a GitHub Copilot profile +- WinUI .NET templates and the WinUI Copilot plugin + +Ubuntu is installed with `--no-launch`. Open it after setup to create the Linux +username and password. + +## Payload layout + +```text +windows-dev-config\ + install.ps1 # sole public entry point and UAC handoff + bootstrap\ + controller.ps1 # checkpointed phase runner + common.ps1 # state, logs, hashes, native process handling + platform.ps1 # WinGet repair, pending reboot, WSL and Ubuntu + resume.ps1 # elevated interactive-user task and restart + configure.ps1 # packages, registry, fonts, Terminal and plugins + user.ps1 # limited-token Copilot plugin configuration + verify.ps1 # independent end-state verification + config\ + packages.json # declarative package inventory + registry.json # declarative registry inventory +``` -### Phase resources (elevation + WSL) +All PowerShell files are signed by the existing pipeline. Hashes for the two JSON +manifests are embedded in signed `bootstrap\common.ps1`. -| Name | Type | What it does | -|------|------|--------------| -| `ElevationCheck` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` checks `IsInRole(Administrator)`. If false, `setScript` re-invokes `winget configure --file --accept-configuration-agreements --disable-interactivity --wait` via `Start-Process -Verb RunAs`, then throws so the unelevated session ends cleanly. | -| `InstallWslComponents` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` probes for the `vmcompute` service (presence ⇒ Virtual Machine Platform is active). `setScript` runs `wsl --install --no-distribution`. | -| `RebootForVmp` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | Same `vmcompute` test. `setScript` registers `HKCU:\...\RunOnce\DSCConfigureResume` with the same `winget configure --file --accept-configuration-agreements` command, then `Restart-Computer -Force` and throws so DSC stops the current run. | -| `InstallUbuntu` | `Microsoft.DSC.Transitional/WindowsPowerShellScript` | `testScript` runs `wsl --list --quiet` and returns true if any distro is already registered. `setScript` runs `wsl --install -d Ubuntu --no-launch`. | +## Restart and elevation model -All app resources that need WSL present depend on `InstallUbuntu` so the OS work happens before the reboot — but the WSL install is still part of the same `winget configure` invocation thanks to the RunOnce resume. +The initial elevated process registers a Task Scheduler entry with: -### Apps +- the initiating user's SID; +- `TASK_LOGON_INTERACTIVE_TOKEN`; +- `TASK_RUNLEVEL_HIGHEST`; +- an at-logon trigger for that same user; +- an action pointing to the pinned, administrator-owned payload. -| Resource name | Package id | Notes | -|---------------|-----------|-------| -| `PowerShell` | `Microsoft.PowerShell` | Direct dependency on `ElevationCheck`. | -| `Git` | `Git.Git` | Depends on `ElevationCheck` + `InstallUbuntu`. | -| `GitHubCLI` | `GitHub.Cli` | Depends on `Git` + `InstallUbuntu`. | -| `GitHubCopilot` | `GitHub.Copilot` | Depends on `Git` + `InstallUbuntu`. | -| `VSCode` | `Microsoft.VisualStudioCode` | | -| `DotnetSdk` | `Microsoft.dotnet.SDK.10` | Pinned to v10. | -| `Python` | `Python.Python.3.14` | Pinned to 3.14. | -| `UV` | `astral-sh.uv` | | -| `NodeJS` | `OpenJS.NodeJS.LTS` | Pinned to the LTS line (currently Node 24 LTS). | -| `nvmForNode` | `CoreyButler.NVMforWindows` | Node version manager for Windows. | -| `Coreutils` | `Microsoft.Coreutils` | Microsoft-maintained Coreutils for Windows. Command integration is handled by the package itself after install. | -| `OhMyPosh` | `JanDeDobbeleer.OhMyPosh` | Marked Optional in the comments. Triggers `ohMyPoshProfileSet`. | -| `winappCli` | `Microsoft.winappcli` | Windows Application CLI. | -| `PowerToys` | `Microsoft.PowerToys` | Marked Optional. Followed by `PowerToysAOT` which disables AOT notifications via registry. | +This preserves the user's HKCU, profile, network access, and interactive desktop +while resuming elevated without a second consent dialog. The task is deleted +after success or terminal failure. -### Theme and OS +The one-UAC behavior requires the signed-in account to be a local administrator +with a UAC split token. Supplying credentials for a different administrator is +rejected because silently resuming that other token would require credential +storage. -Dark theme is applied via a `RunCommandOnSet` resource named `darkTheme` (not via registry): +## Signing the preview -| Resource | Type | What it does | -|----------|------|--------------| -| `darkTheme` | `Microsoft.DSC.Transitional/RunCommandOnSet` | `Start-Process` on `C:\Windows\Resources\Themes\dark.theme`, sleeps 2 s, then stops `SystemSettings` so the Settings window doesn't linger. Depends on `PowerShell`. | - -The remaining theme/OS entries below are `Microsoft.Windows/Registry`. +Queue `.pipelines\OneBranch.SignAndPackage.yml` against the Slipstream branch. +For branch testing, switch the pipeline to the documented non-official OneBranch +template. Test the resulting artifact, not raw branch scripts. -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Sudo enabled (inline mode) | `HKLM\...\Sudo\Enabled` | DWord `3` | -| Developer Mode | `HKLM\...\AppModelUnlock\AllowDevelopmentWithoutDevLicense` | DWord `1` | -| Long path support | `HKLM\...\FileSystem\LongPathsEnabled` | DWord `1` | -| Remote Desktop on | `HKLM\...\Terminal Server\fDenyTSConnections` | DWord `0` | - -### File Explorer - -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Show file extensions | `HKCU\...\Advanced\HideFileExt` | DWord `0` | -| Show hidden files | `HKCU\...\Advanced\Hidden` | DWord `1` | -| Full path in titlebar | `HKCU\...\Advanced\FullPathAddress` | DWord `1` | -| Open to This PC | `HKCU\...\Advanced\LaunchTo` | DWord `1` | -| Frequent folders off | `HKCU\...\Advanced\ShowFrequent` | DWord `0` | -| Frequent files off | `HKCU\...\Explorer\ShowRecent` | DWord `0` | -| Recommended/cloud files off | `HKCU\...\Explorer\ShowCloudFilesInQuickAccess` | DWord `0` | -| Git integration in Explorer | `HKCU\...\Advanced\NavPaneShowVersionControl` | DWord `1` | -| Tips/sync-provider notifications off | `HKCU\...\Advanced\ShowSyncProviderNotifications` | DWord `0` | - -### Taskbar - -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Widgets button hidden | `HKCU\...\Advanced\TaskbarDa` | DWord `0` | -| Bluetooth notification icon off | `HKCU\Control Panel\Bluetooth\Notification Area Icon` | DWord `0` | -| End Task on right-click | `HKCU\...\Advanced\TaskbarEndTask` | DWord `1` | - -### Start, Search, Notifications - -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Web search suggestions off | `HKCU\...\Policies\Explorer\DisableSearchBoxSuggestions` | DWord `1` | -| Search highlights off | `HKCU\...\SearchSettings\IsDynamicSearchBoxEnabled` | DWord `0` | -| Start menu recommendations off | `HKCU\...\Advanced\Start_Layout` | DWord `1` | -| Toast notifications off (Do Not Disturb) | `HKCU\...\Notifications\Settings\NOC_GLOBAL_SETTING_TOASTS_ENABLED` | DWord `0` | - -### Services and features - -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| Widget service off (HKLM policy) | `HKLM\SOFTWARE\Policies\Microsoft\Dsh\AllowNewsAndInterests` | DWord `0` | -| PowerToys AOT notifications off | `HKCU\...\Notifications\Settings\PowerToys\Enabled` | DWord `0` | - -### Edge - -HKLM policies, applied via `Microsoft.Windows/Registry`: - -| Item | Hive\Key\Value | Value | -|------|----------------|-------| -| New tab blank | `HKLM\SOFTWARE\Policies\Microsoft\Edge\NewTabPageLocation` | String `about:blank` | -| First-run experience off | `HKLM\SOFTWARE\Policies\Microsoft\Edge\HideFirstRunExperience` | DWord `1` | - -### Fonts - -| Resource | Type | What it does | -|----------|------|--------------| -| `InstallCascadiaCodeNerdFonts` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Downloads `CascadiaCode-2407.24.zip` from `microsoft/cascadia-code` GitHub Releases, extracts `CascadiaCodeNF.ttf` and `CascadiaMonoNF.ttf` to `%LOCALAPPDATA%\Microsoft\Windows\Fonts`, and registers each under `HKCU\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts`. Per-user install — no admin required for this step. Depends on `PowerShell`. | - -### Windows Terminal - -| Resource | Type | What it does | -|----------|------|--------------| -| `SetCascadiaNfAsDefault` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Locates Windows Terminal's `settings.json` (Store or unpackaged install), backs it up to `settings.json.bak`, and sets `profiles.defaults.font.face = "Cascadia Mono NF"`. Depends on `InstallCascadiaCodeNerdFonts`. | -| `ps7default` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Invokes `pwsh.exe -NoProfile -NoLogo -Command ...` which reads `%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json`, finds the PowerShell 7 profile, and sets it as `defaultProfile`. Depends on `PowerShell`. | - -### PowerShell profile - -| Resource | Type | What it does | -|----------|------|--------------| -| `ohMyPoshProfileSet` | `Microsoft.DSC.Transitional/RunCommandOnSet` | Creates `$PROFILE` if missing and appends `oh-my-posh init pwsh | Invoke-Expression` (idempotent — uses `Select-String` to check first), then dot-sources `$PROFILE`. Depends on `OhMyPosh`. | - ---- - -## Customization - -- **Pick and choose packages.** Comment out any `Microsoft.WinGet/Package` block to skip that install — most have no `dependsOn` chain beyond `InstallUbuntu` (exceptions: `GitHubCLI` and `GitHubCopilot` depend on `Git`; `PowerToysAOT` depends on `PowerToys`; `ohMyPoshProfileSet` depends on `OhMyPosh`). -- **Pin or unpin versions.** Switch `id: Python.Python.3.14` (pinned) to `id: Python.Python.3` if you want to drift forward, or switch `OpenJS.NodeJS.LTS` to `OpenJS.NodeJS` for current. Vice versa for the unpinned packages. -- **Toggle registry values.** Most settings are `DWord: 0` or `DWord: 1`; flip the value to invert the behavior. -- **Re-enable commented-out tweaks.** `HideDesktopIcons` ships commented out (it over-fires on some user setups). Uncomment to enable. -- **Change the WSL distro.** Edit the `wsl --install -d Ubuntu --no-launch` line inside the `InstallUbuntu` resource. -- **Change the terminal font.** Edit `$fontFace = 'Cascadia Mono NF'` inside `SetCascadiaNfAsDefault`, or change the `$WantedFonts` array in `InstallCascadiaCodeNerdFonts` to install a different Cascadia variant. -- **Skip the dark theme step.** Comment out the `darkTheme` resource if you prefer light mode (or want to set it manually). - -## Design decisions - -| Decision | Rationale | -|----------|-----------| -| Single dscv3 document, no modules | Easier to reason about and easier to re-run. The whole flow is one `winget configure` call. | -| `Microsoft.Windows/Registry` everywhere instead of `Microsoft.Windows.Developer/*` or `Microsoft.Windows.Settings/WindowsSettings` | Direct registry control is reliable across Windows 11 builds and avoids dependencies on legacy resource modules. | -| `Microsoft.DSC.Transitional/WindowsPowerShellScript` (not `PSDscResources/Script`) | The dscv3 transitional resource is the supported equivalent under the new processor. | -| Self-relaunch elevated from `ElevationCheck` | A user can double-click into an unelevated shell and the DSC will UAC-prompt itself rather than failing. | -| Reboot + RunOnce inside the DSC | The DSC owns the reboot and the resume, so the user only invokes `winget configure` once. The throw after `Restart-Computer -Force` is required because `Restart-Computer` returns immediately after signalling shutdown; without the throw DSC would treat the resource as succeeded and continue. | -| `useLatest: true` on most packages | Cloud PC parity tracks "current" tools. Pinned ids (`Python.Python.3.14`, `Microsoft.dotnet.SDK.10`, `OpenJS.NodeJS.LTS`) are used where a major-version line matters. | -| Dark theme via `dark.theme` file (not registry) | Applying the shipped `.theme` file flips both `AppsUseLightTheme` and `SystemUsesLightTheme` *and* applies the matching color scheme/cursors atomically, which the broadcast-message dance you'd otherwise need from a registry-only approach often misses. | -| Per-user font install | Avoids requiring admin for the font step and keeps the font registration under `HKCU`, which is what modern Windows + Terminal expect. | -| `RunCommandOnSet` to mutate `settings.json` | Windows Terminal's settings are JSON-based and not registry-mapped; a small pwsh fragment is the cleanest way. | - -## Known caveats - -| Area | Caveat | -|------|--------| -| **`acceptAgreements` not on packages** | None of the `Microsoft.WinGet/Package` resources set `acceptAgreements: true`. The header comment compensates by passing `--accept-configuration-agreements` on the command line. | -| **WSL reboot** | `RebootForVmp` will hard-reboot the machine via `Restart-Computer -Force`. Save your work before running. The RunOnce key resumes the config on next login. | -| **Ubuntu first-launch** | After `InstallUbuntu`, you still need to open Ubuntu from the Start menu once to create a UNIX user. Nothing inside the distro is configured by this flow. | -| **`useLatest: true`** | Each run grabs the latest available version. Builds may differ between machines applying the config on different days. | -| **HKLM registry keys** | Sudo, the Widget service policy, Edge policies, Remote Desktop, Long Paths, and Developer Mode all live in HKLM. The `ElevationCheck` gate guarantees the run is elevated; without it these would silently fail. | -| **PowerToys AOT path** | `HKCU\...\Notifications\Settings\PowerToys\Enabled` targets a specific registry path that may change across PowerToys versions. | -| **Idempotency of WSL phases** | `InstallWslComponents` and `RebootForVmp` both test for `vmcompute`. Re-running after the reboot is a no-op for those resources. `InstallUbuntu` queries `wsl --list --quiet`, so it skips once any distro is registered. | -| **Pinned font release** | `InstallCascadiaCodeNerdFonts` hard-codes Cascadia Code release `2407.24` from `microsoft/cascadia-code`. Bump `$Version` to pick up newer releases. | -| **Windows Terminal settings overwrite** | `SetCascadiaNfAsDefault` and `ps7default` rewrite `settings.json` via `ConvertTo-Json`. `SetCascadiaNfAsDefault` writes a `settings.json.bak` first; `ps7default` does not. JSON comments will not survive the round-trip. | -| **`ohMyPoshProfileSet` runs `. $PROFILE`** | Dot-sourcing the profile inside `pwsh -NoProfile` can surface errors from the user's existing profile during DSC apply. | -| **`darkTheme` opens Settings briefly** | Applying `dark.theme` pops the Settings app open; the script kills it after 2 seconds. On slow machines the window may flash visibly. | -| **Currently commented out** | The `HideDesktopIcons` block lives in the file but is commented out. Uncomment to hide desktop icons. | +Only edit `src\windows-dev-config`. The top-level `windows-dev-config` folder is +the generated signed release copy and must not be edited by hand. diff --git a/src/windows-dev-config/bootstrap/common.ps1 b/src/windows-dev-config/bootstrap/common.ps1 new file mode 100644 index 0000000..ef6836a --- /dev/null +++ b/src/windows-dev-config/bootstrap/common.ps1 @@ -0,0 +1,462 @@ +Set-StrictMode -Version Latest + +$script:SlipstreamProductName = 'WindowsDeveloperConfig' +$script:SlipstreamPayloadVersion = 'slipstream-0.1.0' +$script:SlipstreamProgramDataRoot = Join-Path $env:ProgramData 'Microsoft\WindowsDeveloperConfig' +$script:SlipstreamConfigHashes = @{ + 'config\packages.json' = '75152DFEB6DD08A3718D6CA9486A7D660051E06103B942A029A13F6112DA2BE3' + 'config\registry.json' = '84E5947C1FE4BB0E28411290628FB388444DC15E365C9E2BD04FF41A7E3F15D8' +} + +function Test-SlipstreamAdministrator { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [System.Security.Principal.WindowsPrincipal]::new($identity) + return $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Test-SlipstreamAdministratorMembership { + if (Test-SlipstreamAdministrator) { + return $true + } + + $output = & (Join-Path $env:SystemRoot 'System32\whoami.exe') ` + /groups ` + /fo csv ` + /nh 2>$null + return $LASTEXITCODE -eq 0 -and + (($output -join "`n") -match '(?&1 | ForEach-Object { + $line = $_.ToString() + $lines.Add($line) + Write-Host $line + [System.IO.File]::AppendAllText( + (Get-SlipstreamLogPath -RunId $RunId), + $line + [Environment]::NewLine, + [System.Text.UTF8Encoding]::new($false) + ) + } + $exitCode = $LASTEXITCODE + + if ($exitCode -eq 0) { + return [pscustomobject]@{ + ExitCode = $exitCode + ExitCodeHex = ConvertTo-SlipstreamExitCodeHex -ExitCode $exitCode + Output = @($lines) + } + } + + if ($attempt -lt $MaxAttempts -and + (Test-SlipstreamTransientFailure -ExitCode $exitCode -Output @($lines))) { + $delay = [math]::Min(30, [math]::Pow(2, $attempt + 1)) + (Get-Random -Minimum 0 -Maximum 3) + Write-SlipstreamLog ` + -RunId $RunId ` + -Level WARN ` + -Message "$Name failed transiently with $(ConvertTo-SlipstreamExitCodeHex $exitCode); retrying in ${delay}s." + Start-Sleep -Seconds $delay + continue + } + + if ($AllowAnyExitCode) { + return [pscustomobject]@{ + ExitCode = $exitCode + ExitCodeHex = ConvertTo-SlipstreamExitCodeHex -ExitCode $exitCode + Output = @($lines) + } + } + + throw "$Name failed with $(ConvertTo-SlipstreamExitCodeHex $exitCode) ($exitCode)." + } +} + +function Get-SlipstreamSignedCommand { + param( + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $PublisherPattern + ) + + $commands = @(Get-Command ` + -Name $Name ` + -CommandType Application ` + -All ` + -ErrorAction SilentlyContinue) + foreach ($command in $commands) { + $path = $command.Source + if (-not $path -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { + continue + } + + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -eq 'Valid' -and + $signature.SignerCertificate.Subject -match $PublisherPattern) { + return $path + } + } + + throw "Unable to find a trusted '$Name' signed by the expected publisher." +} + +function Get-SlipstreamDownload { + param( + [Parameter(Mandatory)] [object] $State, + [Parameter(Mandatory)] [uri] $Uri, + [Parameter(Mandatory)] [string] $Destination, + [Parameter(Mandatory)] [string] $Sha256 + ) + + $previousProgress = $ProgressPreference + try { + $ProgressPreference = 'SilentlyContinue' + for ($attempt = 1; $attempt -le 3; $attempt++) { + try { + Invoke-WebRequest ` + -Uri $Uri ` + -OutFile $Destination ` + -UseBasicParsing ` + -ErrorAction Stop + break + } + catch { + if ($attempt -eq 3) { + throw + } + $delay = [math]::Pow(2, $attempt + 1) + Write-SlipstreamLog ` + -RunId $State.runId ` + -Level WARN ` + -Message "Download failed; retrying in ${delay}s: $Uri" + Start-Sleep -Seconds $delay + } + } + } + finally { + $ProgressPreference = $previousProgress + } + + $actual = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256).Hash + if ($actual -ne $Sha256) { + Remove-Item -LiteralPath $Destination -Force + throw "Hash mismatch for $Uri. Expected $Sha256, got $actual." + } +} + +function Refresh-SlipstreamPath { + $machine = [Environment]::GetEnvironmentVariable('Path', 'Machine') + $user = [Environment]::GetEnvironmentVariable('Path', 'User') + $parts = @($machine, $user) | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $env:Path = $parts -join ';' +} + +function ConvertTo-SlipstreamRegistryPath { + param([Parameter(Mandatory)] [string] $Path) + + if ($Path.StartsWith('HKLM\', [System.StringComparison]::OrdinalIgnoreCase)) { + return 'Registry::HKEY_LOCAL_MACHINE\' + $Path.Substring(5) + } + if ($Path.StartsWith('HKCU\', [System.StringComparison]::OrdinalIgnoreCase)) { + return 'Registry::HKEY_CURRENT_USER\' + $Path.Substring(5) + } + throw "Unsupported registry hive in '$Path'." +} + +function Get-SlipstreamConfigTextHash { + param([Parameter(Mandatory)] [string] $Path) + + $bytes = [IO.File]::ReadAllBytes($Path) + if ($bytes.Length -ge 3 -and + $bytes[0] -eq 0xEF -and + $bytes[1] -eq 0xBB -and + $bytes[2] -eq 0xBF) { + $bytes = $bytes[3..($bytes.Length - 1)] + } + $text = [Text.Encoding]::UTF8.GetString($bytes) + $normalized = [Text.Encoding]::UTF8.GetBytes( + ($text -replace "`r`n", "`n") + ) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString( + $sha256.ComputeHash($normalized) + ).Replace('-', '') + } + finally { + $sha256.Dispose() + } +} + +function Test-SlipstreamPayload { + param( + [Parameter(Mandatory)] [string] $PayloadRoot, + [switch] $AllowUnsigned + ) + + $requiredFiles = @( + 'install.ps1', + 'bootstrap\common.ps1', + 'bootstrap\controller.ps1', + 'bootstrap\platform.ps1', + 'bootstrap\resume.ps1', + 'bootstrap\configure.ps1', + 'bootstrap\user.ps1', + 'bootstrap\verify.ps1', + 'config\packages.json', + 'config\registry.json' + ) + foreach ($relativePath in $requiredFiles) { + $path = Join-Path $PayloadRoot $relativePath + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Slipstream payload is incomplete; missing $relativePath." + } + } + + foreach ($scriptPath in Get-ChildItem -LiteralPath $PayloadRoot -Recurse -Filter *.ps1 -File) { + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $scriptPath.FullName, + [ref]$null, + [ref]$parseErrors + ) + if ($parseErrors) { + throw "PowerShell parse failure in $($scriptPath.FullName): $($parseErrors[0].Message)" + } + + if (-not $AllowUnsigned) { + $signature = Get-AuthenticodeSignature -LiteralPath $scriptPath.FullName + if ($signature.Status -ne 'Valid') { + throw "Invalid Authenticode signature on $($scriptPath.Name): $($signature.Status)" + } + if ($signature.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') { + throw "Unexpected signer on $($scriptPath.Name): $($signature.SignerCertificate.Subject)" + } + } + } + + foreach ($relativePath in $script:SlipstreamConfigHashes.Keys) { + $path = Join-Path $PayloadRoot $relativePath + $actual = Get-SlipstreamConfigTextHash -Path $path + $expected = $script:SlipstreamConfigHashes[$relativePath] + if ($actual -ne $expected) { + throw "Hash mismatch for $relativePath. Expected $expected, got $actual." + } + } + + $packages = Get-Content (Join-Path $PayloadRoot 'config\packages.json') -Raw -Encoding UTF8 | + ConvertFrom-Json + $registry = Get-Content (Join-Path $PayloadRoot 'config\registry.json') -Raw -Encoding UTF8 | + ConvertFrom-Json + if ($packages.schemaVersion -ne 1 -or @($packages.packages).Count -eq 0) { + throw 'packages.json has an unsupported or empty schema.' + } + if ($registry.schemaVersion -ne 1 -or @($registry.values).Count -eq 0) { + throw 'registry.json has an unsupported or empty schema.' + } + + $duplicatePackages = @($packages.packages | Group-Object id | Where-Object Count -gt 1) + if ($duplicatePackages.Count -gt 0) { + throw "packages.json contains duplicate ids: $($duplicatePackages.Name -join ', ')" + } + $duplicateRegistryNames = @($registry.values | Group-Object name | Where-Object Count -gt 1) + if ($duplicateRegistryNames.Count -gt 0) { + throw "registry.json contains duplicate names: $($duplicateRegistryNames.Name -join ', ')" + } + + return [pscustomobject]@{ + Scripts = @(Get-ChildItem -LiteralPath $PayloadRoot -Recurse -Filter *.ps1 -File).Count + Packages = @($packages.packages).Count + RegistryValues = @($registry.values).Count + SignaturesRequired = -not $AllowUnsigned + } +} + +function New-SlipstreamPhaseResult { + param( + [switch] $RebootRequired, + [bool] $AdvancePhase = $true, + [string] $Reason + ) + + return [pscustomobject]@{ + RebootRequired = [bool]$RebootRequired + AdvancePhase = $AdvancePhase + Reason = $Reason + } +} diff --git a/src/windows-dev-config/bootstrap/configure.ps1 b/src/windows-dev-config/bootstrap/configure.ps1 new file mode 100644 index 0000000..6c7689b --- /dev/null +++ b/src/windows-dev-config/bootstrap/configure.ps1 @@ -0,0 +1,460 @@ +Set-StrictMode -Version Latest + +function Get-SlipstreamPackageManifest { + param([Parameter(Mandatory)] [string] $PayloadRoot) + + return Get-Content ` + -LiteralPath (Join-Path $PayloadRoot 'config\packages.json') ` + -Raw ` + -Encoding UTF8 | ConvertFrom-Json +} + +function Get-SlipstreamRegistryManifest { + param([Parameter(Mandatory)] [string] $PayloadRoot) + + return Get-Content ` + -LiteralPath (Join-Path $PayloadRoot 'config\registry.json') ` + -Raw ` + -Encoding UTF8 | ConvertFrom-Json +} + +function Test-SlipstreamPackageInstalled { + param( + [Parameter(Mandatory)] [string] $WinGetPath, + [Parameter(Mandatory)] [string] $Id + ) + + $output = & $WinGetPath list ` + --id $Id ` + --exact ` + --disable-interactivity ` + --accept-source-agreements 2>&1 + $exitCode = $LASTEXITCODE + return $exitCode -eq 0 -and (($output -join "`n") -match [regex]::Escape($Id)) +} + +function Install-SlipstreamPackage { + param( + [Parameter(Mandatory)] [object] $State, + [Parameter(Mandatory)] [object] $Package + ) + + $winget = Get-SlipstreamWinGetCommand + if (-not $winget) { + throw 'winget.exe disappeared after platform repair.' + } + + if (Test-SlipstreamPackageInstalled -WinGetPath $winget -Id $Package.id) { + Write-SlipstreamLog ` + -RunId $State.runId ` + -Message "Package already installed: $($Package.name) [$($Package.id)]" + return New-SlipstreamPhaseResult + } + + $arguments = @( + 'install', + '--id', $Package.id, + '--exact', + '--source', $Package.source, + '--silent', + '--accept-package-agreements', + '--accept-source-agreements', + '--disable-interactivity' + ) + if ($Package.PSObject.Properties.Name -contains 'scope' -and $Package.scope) { + $arguments += @('--scope', $Package.scope) + } + if ($Package.PSObject.Properties.Name -contains 'arguments' -and $Package.arguments) { + $arguments += @($Package.arguments) + } + + $result = Invoke-SlipstreamNative ` + -RunId $State.runId ` + -FilePath $winget ` + -ArgumentList $arguments ` + -Name "Install $($Package.name)" ` + -MaxAttempts 3 ` + -AllowAnyExitCode + + $rebootCodes = @( + '0x00000669', + '0x00000BC2', + '0x8A150109', + '0x8A15010A', + '0x8A15010B' + ) + if ($result.ExitCodeHex -in $rebootCodes) { + return New-SlipstreamPhaseResult ` + -RebootRequired ` + -AdvancePhase:$false ` + -Reason "Package:$($Package.id)" + } + if ($result.ExitCode -ne 0) { + throw "WinGet failed to install $($Package.id): $($result.ExitCodeHex)" + } + + return New-SlipstreamPhaseResult +} + +function Install-SlipstreamPackages { + param([Parameter(Mandatory)] [object] $State) + + $manifest = Get-SlipstreamPackageManifest -PayloadRoot $State.payloadRoot + foreach ($package in $manifest.packages) { + $result = Install-SlipstreamPackage -State $State -Package $package + if ($result.RebootRequired) { + return $result + } + } + + Refresh-SlipstreamPath + return New-SlipstreamPhaseResult +} + +function Test-SlipstreamRegistryValue { + param([Parameter(Mandatory)] [object] $Entry) + + $path = ConvertTo-SlipstreamRegistryPath -Path $Entry.path + $current = Get-ItemPropertyValue ` + -LiteralPath $path ` + -Name $Entry.valueName ` + -ErrorAction SilentlyContinue + if ($null -eq $current) { + return $false + } + + if ($Entry.type -eq 'DWord') { + return [int64]$current -eq [int64]$Entry.value + } + return [string]$current -ceq [string]$Entry.value +} + +function Set-SlipstreamRegistryValue { + param( + [Parameter(Mandatory)] [string] $RunId, + [Parameter(Mandatory)] [object] $Entry + ) + + if (Test-SlipstreamRegistryValue -Entry $Entry) { + Write-SlipstreamLog -RunId $RunId -Message "Registry already configured: $($Entry.name)" + return + } + + $path = ConvertTo-SlipstreamRegistryPath -Path $Entry.path + New-Item -Path $path -Force | Out-Null + New-ItemProperty ` + -LiteralPath $path ` + -Name $Entry.valueName ` + -Value $Entry.value ` + -PropertyType $Entry.type ` + -Force | Out-Null + + if (-not (Test-SlipstreamRegistryValue -Entry $Entry)) { + throw "Registry verification failed: $($Entry.name)" + } + Write-SlipstreamLog -RunId $RunId -Message "Configured registry: $($Entry.name)" +} + +function Set-SlipstreamRegistryConfiguration { + param([Parameter(Mandatory)] [object] $State) + + $manifest = Get-SlipstreamRegistryManifest -PayloadRoot $State.payloadRoot + foreach ($entry in $manifest.values) { + Set-SlipstreamRegistryValue -RunId $State.runId -Entry $entry + } + + $rundll32 = Join-Path $env:SystemRoot 'System32\rundll32.exe' + & $rundll32 user32.dll,UpdatePerUserSystemParameters 1, $true 2>$null + Write-SlipstreamLog -RunId $State.runId -Message 'Broadcast the per-user settings refresh.' +} + +function Test-SlipstreamCascadiaFonts { + $fontsDirectory = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' + $fontNames = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') + $registryPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' + $registryItem = Get-ItemProperty $registryPath -ErrorAction SilentlyContinue + $registryValues = if ($registryItem) { + @($registryItem.PSObject.Properties | + Where-Object Name -notin @('PSPath', 'PSParentPath', 'PSChildName', 'PSDrive', 'PSProvider') | + ForEach-Object Value) + } + else { + @() + } + + foreach ($fontName in $fontNames) { + if (-not (Test-Path -LiteralPath (Join-Path $fontsDirectory $fontName))) { + return $false + } + if (-not ($registryValues | Where-Object { $_ -like "*\$fontName" })) { + return $false + } + } + return $true +} + +function Install-SlipstreamCascadiaFonts { + param([Parameter(Mandatory)] [object] $State) + + if (Test-SlipstreamCascadiaFonts) { + Write-SlipstreamLog -RunId $State.runId -Message 'Cascadia Nerd Fonts are already installed.' + return + } + + $version = '2407.24' + $fontNames = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') + $uri = "https://github.com/microsoft/cascadia-code/releases/download/v$version/CascadiaCode-$version.zip" + $workDirectory = Join-Path $env:TEMP "WindowsDeveloperConfig-Cascadia-$version" + $zipPath = Join-Path $workDirectory 'CascadiaCode.zip' + $fontsDirectory = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' + $registryPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' + + New-Item -ItemType Directory -Path $workDirectory -Force | Out-Null + New-Item -ItemType Directory -Path $fontsDirectory -Force | Out-Null + New-Item -Path $registryPath -Force | Out-Null + try { + Get-SlipstreamDownload ` + -State $State ` + -Uri $uri ` + -Destination $zipPath ` + -Sha256 'E67A68EE3386DB63F48B9054BD196EA752BC6A4EBB4DF35ADCE6733DA50C8474' + + Add-Type -AssemblyName System.IO.Compression.FileSystem + Add-Type -AssemblyName System.Drawing + $archive = [System.IO.Compression.ZipFile]::OpenRead($zipPath) + try { + foreach ($fontName in $fontNames) { + $entry = $archive.Entries | + Where-Object Name -eq $fontName | + Select-Object -First 1 + if (-not $entry) { + throw "$fontName is missing from Cascadia Code $version." + } + + $destination = Join-Path $fontsDirectory $fontName + [System.IO.Compression.ZipFileExtensions]::ExtractToFile( + $entry, + $destination, + $true + ) + + $collection = New-Object System.Drawing.Text.PrivateFontCollection + try { + $collection.AddFontFile($destination) + $family = $collection.Families[0].Name + } + finally { + $collection.Dispose() + } + New-ItemProperty ` + -Path $registryPath ` + -Name "$family (TrueType)" ` + -Value $destination ` + -PropertyType String ` + -Force | Out-Null + } + } + finally { + $archive.Dispose() + } + } + finally { + if (Test-Path -LiteralPath $workDirectory) { + Remove-Item -LiteralPath $workDirectory -Recurse -Force + } + } + + if (-not (Test-SlipstreamCascadiaFonts)) { + throw 'Cascadia Nerd Font verification failed.' + } + Write-SlipstreamLog -RunId $State.runId -Message 'Installed Cascadia Code and Mono Nerd Fonts.' +} + +function Add-SlipstreamProperty { + param( + [Parameter(Mandatory)] [object] $InputObject, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [object] $Value + ) + + if ($InputObject.PSObject.Properties.Name -notcontains $Name) { + $InputObject | Add-Member -MemberType NoteProperty -Name $Name -Value $Value + } + elseif ($null -eq $InputObject.$Name) { + $InputObject.$Name = $Value + } +} + +function Get-SlipstreamTerminalSettingsPath { + $package = Get-AppxPackage -Name Microsoft.WindowsTerminal -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | + Select-Object -First 1 + if ($package) { + return Join-Path ` + (Join-Path $env:LOCALAPPDATA "Packages\$($package.PackageFamilyName)\LocalState") ` + 'settings.json' + } + + return Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\settings.json' +} + +function Set-SlipstreamTerminalSettings { + param([Parameter(Mandatory)] [object] $State) + + $settingsPath = Get-SlipstreamTerminalSettingsPath + $settingsDirectory = Split-Path -Parent $settingsPath + New-Item -ItemType Directory -Path $settingsDirectory -Force | Out-Null + + $raw = '{}' + if (Test-Path -LiteralPath $settingsPath) { + Copy-Item -LiteralPath $settingsPath -Destination "$settingsPath.slipstream.bak" -Force + $raw = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 + } + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + $clean = [regex]::Replace($clean, ',\s*([}\]])', '$1') + if ([string]::IsNullOrWhiteSpace($clean)) { + $clean = '{}' + } + $settings = $clean | ConvertFrom-Json + + Add-SlipstreamProperty -InputObject $settings -Name profiles -Value ([pscustomobject]@{}) + Add-SlipstreamProperty -InputObject $settings.profiles -Name defaults -Value ([pscustomobject]@{}) + Add-SlipstreamProperty -InputObject $settings.profiles.defaults -Name font -Value ([pscustomobject]@{}) + Add-SlipstreamProperty ` + -InputObject $settings.profiles.defaults.font ` + -Name face ` + -Value 'Cascadia Mono NF' + $settings.profiles.defaults.font.face = 'Cascadia Mono NF' + + $powerShellProfileGuid = '{574e775e-4f2a-5b96-ac1e-a2962a402336}' + Add-SlipstreamProperty ` + -InputObject $settings ` + -Name defaultProfile ` + -Value $powerShellProfileGuid + $settings.defaultProfile = $powerShellProfileGuid + + $settings | ConvertTo-Json -Depth 32 | + Set-Content -LiteralPath $settingsPath -Encoding UTF8 + Write-SlipstreamLog -RunId $State.runId -Message "Configured Windows Terminal: $settingsPath" +} + +function Set-SlipstreamCopilotTerminalProfile { + param([Parameter(Mandatory)] [object] $State) + + $fragmentsDirectory = Join-Path ` + $env:LOCALAPPDATA ` + 'Microsoft\Windows Terminal\Fragments\WindowsDeveloperConfig' + New-Item -ItemType Directory -Path $fragmentsDirectory -Force | Out-Null + + $fragment = [ordered]@{ + profiles = @( + [ordered]@{ + guid = '{b1a4d2c8-6f3e-4a7b-9e2d-1c8f5a3b7d91}' + name = 'GitHub Copilot' + commandline = 'pwsh.exe -NoExit -Command "copilot"' + startingDirectory = '%USERPROFILE%' + hidden = $false + tabTitle = 'Copilot' + } + ) + } + $fragmentPath = Join-Path $fragmentsDirectory 'github-copilot.fragment.json' + $fragment | ConvertTo-Json -Depth 8 | + Set-Content -LiteralPath $fragmentPath -Encoding UTF8 + Write-SlipstreamLog -RunId $State.runId -Message "Created GitHub Copilot Terminal profile: $fragmentPath" +} + +function Set-SlipstreamOhMyPoshProfile { + param([Parameter(Mandatory)] [object] $State) + + $pwsh = Get-SlipstreamSignedCommand ` + -Name pwsh.exe ` + -PublisherPattern 'O=Microsoft Corporation' + $profileOutput = & $pwsh -NoLogo -NoProfile -Command '$PROFILE' 2>$null | + Select-Object -First 1 + if ($LASTEXITCODE -ne 0 -or -not $profileOutput) { + throw 'Unable to resolve the PowerShell 7 profile path.' + } + $profilePath = $profileOutput.ToString().Trim() + + $marker = '# Windows Developer Config: Oh My Posh' + $existing = if (Test-Path -LiteralPath $profilePath) { + Get-Content -LiteralPath $profilePath -Raw -Encoding UTF8 + } else { + '' + } + if ($existing -match [regex]::Escape($marker)) { + Write-SlipstreamLog -RunId $State.runId -Message 'Oh My Posh is already configured in the PowerShell 7 profile.' + return + } + + $profileDirectory = Split-Path -Parent $profilePath + New-Item -ItemType Directory -Path $profileDirectory -Force | Out-Null + $block = @" + +$marker +if (Get-Command oh-my-posh -ErrorAction SilentlyContinue) { + oh-my-posh init pwsh | Invoke-Expression +} +"@ + [System.IO.File]::AppendAllText( + $profilePath, + $block, + [System.Text.UTF8Encoding]::new($false) + ) + Write-SlipstreamLog -RunId $State.runId -Message "Configured Oh My Posh in $profilePath" +} + +function Install-SlipstreamWinUiTemplates { + param([Parameter(Mandatory)] [object] $State) + + $dotnet = Get-SlipstreamSignedCommand ` + -Name dotnet.exe ` + -PublisherPattern 'O=Microsoft Corporation' + $output = & $dotnet new list 2>&1 + if ($LASTEXITCODE -eq 0 -and ($output -join "`n") -match '(?i)winui') { + Write-SlipstreamLog -RunId $State.runId -Message 'WinUI .NET templates are already installed.' + return + } + + Invoke-SlipstreamNative ` + -RunId $State.runId ` + -FilePath $dotnet ` + -ArgumentList @('new', 'install', 'Microsoft.WindowsAppSDK.WinUI.CSharp.Templates') ` + -Name 'Install WinUI .NET templates' | Out-Null +} + +function Invoke-SlipstreamComplexConfiguration { + param( + [Parameter(Mandatory)] [object] $State, + [switch] $AllowUnsigned + ) + + Install-SlipstreamCascadiaFonts -State $State + Set-SlipstreamTerminalSettings -State $State + Set-SlipstreamCopilotTerminalProfile -State $State + Set-SlipstreamOhMyPoshProfile -State $State + Install-SlipstreamWinUiTemplates -State $State + Invoke-SlipstreamUserConfiguration ` + -State $State ` + -AllowUnsigned:$AllowUnsigned +} + +function Invoke-SlipstreamDesiredState { + param( + [Parameter(Mandatory)] [object] $State, + [switch] $AllowUnsigned + ) + + $packageResult = Install-SlipstreamPackages -State $State + if ($packageResult.RebootRequired) { + return $packageResult + } + + Set-SlipstreamRegistryConfiguration -State $State + Invoke-SlipstreamComplexConfiguration ` + -State $State ` + -AllowUnsigned:$AllowUnsigned + return New-SlipstreamPhaseResult +} diff --git a/src/windows-dev-config/bootstrap/controller.ps1 b/src/windows-dev-config/bootstrap/controller.ps1 new file mode 100644 index 0000000..8898fb4 --- /dev/null +++ b/src/windows-dev-config/bootstrap/controller.ps1 @@ -0,0 +1,236 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] [string] $RunId, + [Parameter(Mandatory)] [string] $PayloadRoot, + [string] $OriginalUserSid, + [string] $OriginalUserName, + [switch] $AllowUnsigned, + [switch] $NoRestart +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'common.ps1') +. (Join-Path $PSScriptRoot 'resume.ps1') +. (Join-Path $PSScriptRoot 'platform.ps1') +. (Join-Path $PSScriptRoot 'configure.ps1') +. (Join-Path $PSScriptRoot 'verify.ps1') + +$phases = @( + 'Preflight', + 'RepairPlatform', + 'ClearRequiredPendingReboot', + 'EnableWslPlatform', + 'InstallWslDistro', + 'ApplyDesiredState', + 'Verify' +) + +function New-SlipstreamState { + if ([string]::IsNullOrWhiteSpace($OriginalUserSid) -or + [string]::IsNullOrWhiteSpace($OriginalUserName)) { + throw 'Original user identity is required when creating a Slipstream run.' + } + + return [pscustomobject][ordered]@{ + schemaVersion = 1 + productVersion = $script:SlipstreamPayloadVersion + runId = $RunId + payloadRoot = $PayloadRoot + originalUserSid = $OriginalUserSid + originalUserName = $OriginalUserName + status = 'Running' + phaseIndex = 0 + phase = $phases[0] + completedPhases = @() + rebootCount = 0 + rebootBootId = $null + rebootReason = $null + rebootHistory = @() + createdAtUtc = [DateTime]::UtcNow.ToString('o') + updatedAtUtc = [DateTime]::UtcNow.ToString('o') + lastError = $null + } +} + +function Move-SlipstreamToNextPhase { + param([Parameter(Mandatory)] [object] $State) + + $State.completedPhases = @($State.completedPhases) + $State.phase + $State.phaseIndex = [int]$State.phaseIndex + 1 + if ([int]$State.phaseIndex -lt $phases.Count) { + $State.phase = $phases[$State.phaseIndex] + } + else { + $State.phase = 'Complete' + } + Save-SlipstreamState -State $State +} + +function Invoke-SlipstreamPhase { + param([Parameter(Mandatory)] [object] $State) + + Write-SlipstreamLog -RunId $State.runId -Message "Starting phase: $($State.phase)" + $result = switch ($State.phase) { + 'Preflight' { + Invoke-SlipstreamPreflight -State $State -AllowUnsigned:$AllowUnsigned + } + 'RepairPlatform' { + Invoke-SlipstreamPlatformRepair -State $State + } + 'ClearRequiredPendingReboot' { + Invoke-SlipstreamPendingRebootGate -State $State + } + 'EnableWslPlatform' { + Invoke-SlipstreamWslPlatform -State $State + } + 'InstallWslDistro' { + Invoke-SlipstreamWslDistro -State $State + } + 'ApplyDesiredState' { + Invoke-SlipstreamDesiredState ` + -State $State ` + -AllowUnsigned:$AllowUnsigned + } + 'Verify' { + Invoke-SlipstreamVerification ` + -State $State ` + -AllowUnsigned:$AllowUnsigned + } + default { + throw "Unknown Slipstream phase: $($State.phase)" + } + } + + if (-not $result) { + throw "Phase $($State.phase) returned no result." + } + return $result +} + +$mutex = [System.Threading.Mutex]::new( + $false, + 'Global\Microsoft.WindowsDeveloperConfig.Slipstream' +) +$ownsMutex = $false +$state = $null + +try { + try { + $ownsMutex = $mutex.WaitOne(0) + } + catch [System.Threading.AbandonedMutexException] { + $ownsMutex = $true + } + if (-not $ownsMutex) { + throw 'Another Windows Developer Config run is already active.' + } + + $statePath = Get-SlipstreamStatePath -RunId $RunId + if (Test-Path -LiteralPath $statePath) { + $state = Read-SlipstreamState -RunId $RunId + if ($state.payloadRoot -ne $PayloadRoot) { + throw "Payload path mismatch. State pins '$($state.payloadRoot)', caller supplied '$PayloadRoot'." + } + } + else { + $state = New-SlipstreamState + Save-SlipstreamState -State $state + } + + if ($state.status -eq 'Complete') { + Write-SlipstreamLog -RunId $RunId -Message 'This Slipstream run is already complete.' + return 0 + } + + if ($state.status -eq 'WaitingForReboot') { + $currentBootId = Get-SlipstreamBootId + if ($currentBootId -eq $state.rebootBootId) { + Write-SlipstreamLog ` + -RunId $RunId ` + -Level WARN ` + -Message "A restart is still required: $($state.rebootReason)" + if (-not $NoRestart) { + $restart = Invoke-SlipstreamNative ` + -RunId $RunId ` + -FilePath (Join-Path $env:SystemRoot 'System32\shutdown.exe') ` + -ArgumentList @('/r', '/t', '15', '/d', 'p:4:1', '/c', 'Windows Developer Config will resume after sign-in.') ` + -Name 'Reschedule Windows restart' ` + -AllowAnyExitCode + if ($restart.ExitCode -ne 0) { + throw "Unable to reschedule restart: $($restart.ExitCodeHex)" + } + } + return 3010 + } + + $state.status = 'Running' + $state.rebootBootId = $null + $state.rebootReason = $null + Save-SlipstreamState -State $state + Write-SlipstreamLog -RunId $RunId -Message 'Restart confirmed; resuming setup.' + } + + Register-SlipstreamResumeTask ` + -State $state ` + -AllowUnsigned:$AllowUnsigned ` + -NoRestart:$NoRestart + + while ([int]$state.phaseIndex -lt $phases.Count) { + $result = Invoke-SlipstreamPhase -State $state + if ($result.RebootRequired) { + if ($result.AdvancePhase) { + Move-SlipstreamToNextPhase -State $state + } + else { + Save-SlipstreamState -State $state + } + + Request-SlipstreamRestart ` + -State $state ` + -Reason $result.Reason ` + -NoRestart:$NoRestart + return 3010 + } + + Write-SlipstreamLog -RunId $RunId -Message "Completed phase: $($state.phase)" + Move-SlipstreamToNextPhase -State $state + } + + $state.status = 'Complete' + $state.lastError = $null + Save-SlipstreamState -State $state + Unregister-SlipstreamResumeTask -RunId $RunId + Write-SlipstreamLog -RunId $RunId -Message 'Windows Developer Config completed successfully.' + Write-Host '' + Write-Host 'Windows Developer Config is complete.' -ForegroundColor Green + Write-Host "Log: $(Get-SlipstreamLogPath -RunId $RunId)" -ForegroundColor DarkGray + return 0 +} +catch { + if ($ownsMutex -and $state) { + $state.status = 'Failed' + $state.lastError = [pscustomobject]@{ + phase = $state.phase + message = $_.Exception.Message + atUtc = [DateTime]::UtcNow.ToString('o') + } + Save-SlipstreamState -State $state + Write-SlipstreamLog ` + -RunId $RunId ` + -Level ERROR ` + -Message "Setup failed in phase $($state.phase): $($_.Exception.Message)" + Unregister-SlipstreamResumeTask -RunId $RunId + } + elseif ($ownsMutex) { + Unregister-SlipstreamResumeTask -RunId $RunId + } + throw +} +finally { + if ($ownsMutex) { + $mutex.ReleaseMutex() + } + $mutex.Dispose() +} diff --git a/src/windows-dev-config/bootstrap/platform.ps1 b/src/windows-dev-config/bootstrap/platform.ps1 new file mode 100644 index 0000000..55d3f79 --- /dev/null +++ b/src/windows-dev-config/bootstrap/platform.ps1 @@ -0,0 +1,441 @@ +Set-StrictMode -Version Latest + +function Get-SlipstreamWinGetCommand { + $package = Get-AppxPackage ` + -Name Microsoft.DesktopAppInstaller ` + -ErrorAction SilentlyContinue | + Sort-Object Version -Descending | + Select-Object -First 1 + if (-not $package) { + return $null + } + + if ($package.PublisherId -ne '8wekyb3d8bbwe' -or + $package.Publisher -notmatch 'O=Microsoft Corporation') { + throw "The registered App Installer package has an unexpected publisher: $($package.Publisher)" + } + + $wingetPath = Join-Path $package.InstallLocation 'winget.exe' + $windowsAppsRoot = Join-Path ` + ([Environment]::GetFolderPath([Environment+SpecialFolder]::ProgramFiles)) ` + 'WindowsApps' + $trustedRoot = [IO.Path]::GetFullPath($windowsAppsRoot).TrimEnd('\') + '\' + $fullPath = [IO.Path]::GetFullPath($wingetPath) + if (-not $fullPath.StartsWith( + $trustedRoot, + [StringComparison]::OrdinalIgnoreCase + )) { + throw "App Installer resolved outside the protected WindowsApps directory: $fullPath" + } + if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { + return $null + } + + $signature = Get-AuthenticodeSignature -LiteralPath $fullPath + if ($signature.Status -ne 'Valid' -or + $signature.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') { + throw "winget.exe does not have a valid Microsoft signature: $($signature.Status)" + } + return $fullPath +} + +function Get-SlipstreamWinGetVersion { + $winget = Get-SlipstreamWinGetCommand + if (-not $winget) { + return $null + } + + try { + $output = @(& $winget --version 2>$null) + $exitCode = $LASTEXITCODE + } + catch { + return $null + } + $versionLine = $output | Select-Object -First 1 + if ($exitCode -ne 0 -or -not $versionLine) { + return $null + } + $raw = $versionLine.ToString().Trim() + if ($raw -notmatch '^v?(\d+\.\d+\.\d+)') { + return $null + } + return [version]$Matches[1] +} + +function Install-SlipstreamWinGetRelease { + param([Parameter(Mandatory)] [object] $State) + + Write-SlipstreamLog ` + -RunId $State.runId ` + -Level WARN ` + -Message 'Falling back to the latest signed WinGet GitHub release.' + + $headers = @{ + 'User-Agent' = 'Microsoft-WindowsDeveloperConfig' + 'Accept' = 'application/vnd.github+json' + } + $release = Invoke-RestMethod ` + -Uri 'https://api.github.com/repos/microsoft/winget-cli/releases/latest' ` + -Headers $headers ` + -UseBasicParsing ` + -ErrorAction Stop + + $bundleAsset = $release.assets | + Where-Object name -eq 'Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle' | + Select-Object -First 1 + $dependenciesAsset = $release.assets | + Where-Object name -eq 'DesktopAppInstaller_Dependencies.zip' | + Select-Object -First 1 + if (-not $bundleAsset -or -not $dependenciesAsset) { + throw "WinGet release $($release.tag_name) is missing required assets." + } + if ($bundleAsset.digest -notmatch '^sha256:([0-9a-fA-F]{64})$' -or + $dependenciesAsset.digest -notmatch '^sha256:([0-9a-fA-F]{64})$') { + throw "WinGet release $($release.tag_name) did not publish SHA256 asset digests." + } + $bundleHash = $bundleAsset.digest.Substring(7).ToUpperInvariant() + $dependenciesHash = $dependenciesAsset.digest.Substring(7).ToUpperInvariant() + + $workDirectory = Join-Path ` + (Get-SlipstreamRunRoot -RunId $State.runId) ` + "winget-$($release.tag_name)" + $bundlePath = Join-Path $workDirectory $bundleAsset.name + $dependenciesZip = Join-Path $workDirectory $dependenciesAsset.name + $dependenciesRoot = Join-Path $workDirectory 'dependencies' + + New-Item -ItemType Directory -Path $workDirectory -Force | Out-Null + try { + Get-SlipstreamDownload ` + -State $State ` + -Uri $bundleAsset.browser_download_url ` + -Destination $bundlePath ` + -Sha256 $bundleHash + Get-SlipstreamDownload ` + -State $State ` + -Uri $dependenciesAsset.browser_download_url ` + -Destination $dependenciesZip ` + -Sha256 $dependenciesHash + Expand-Archive ` + -LiteralPath $dependenciesZip ` + -DestinationPath $dependenciesRoot ` + -Force + + $nativeArchitecture = @( + $env:PROCESSOR_ARCHITECTURE, + $env:PROCESSOR_ARCHITEW6432 + ) -join ';' + $architecture = if ($nativeArchitecture -match '(?i)ARM64') { + 'arm64' + } + else { + 'x64' + } + $dependencyPaths = @( + Get-ChildItem ` + -LiteralPath (Join-Path $dependenciesRoot $architecture) ` + -Filter *.appx ` + -File | + Select-Object -ExpandProperty FullName + ) + if ($dependencyPaths.Count -eq 0) { + throw "No $architecture WinGet dependencies were present in the release." + } + + Add-AppxPackage ` + -Path $bundlePath ` + -DependencyPath $dependencyPaths ` + -ForceApplicationShutdown ` + -ForceUpdateFromAnyVersion ` + -ErrorAction Stop + } + finally { + if (Test-Path -LiteralPath $workDirectory) { + Remove-Item -LiteralPath $workDirectory -Recurse -Force + } + } +} + +function Invoke-SlipstreamPreflight { + param( + [Parameter(Mandatory)] [object] $State, + [switch] $AllowUnsigned + ) + + if (-not (Test-SlipstreamAdministrator)) { + throw 'Slipstream controller must run elevated.' + } + + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + if ($identity.User.Value -ne $State.originalUserSid) { + throw @" +The elevated process is running as a different account. +Started as: $($State.originalUserName) ($($State.originalUserSid)) +Elevated as: $($identity.Name) ($($identity.User.Value)) + +Slipstream requires an administrator account with a UAC split token so it can +resume as the same user after reboot without storing another account's password. +"@ + } + + if (-not [Environment]::Is64BitOperatingSystem) { + throw 'Windows Developer Config requires 64-bit Windows 11.' + } + if (-not [Environment]::Is64BitProcess) { + throw 'Slipstream must run in 64-bit Windows PowerShell.' + } + + $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop + if ([int]$os.BuildNumber -lt 22000) { + throw "Windows 11 build 22000 or later is required. Found build $($os.BuildNumber)." + } + + $systemDrive = Get-PSDrive -Name $env:SystemDrive.TrimEnd(':') -ErrorAction Stop + $freeGb = [math]::Round($systemDrive.Free / 1GB, 1) + if ($freeGb -lt 15) { + throw "At least 15 GB free on $env:SystemDrive is required. Found ${freeGb} GB." + } + + foreach ($command in @( + 'New-ScheduledTask', + 'Register-ScheduledTask', + 'Get-WindowsOptionalFeature' + )) { + if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { + throw "Required inbox Windows command is unavailable: $command" + } + } + + $policyPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\AppInstaller' + foreach ($policyName in @('EnableAppInstaller', 'EnableWindowsPackageManagerCommandLineInterfaces')) { + $value = Get-ItemPropertyValue ` + -Path $policyPath ` + -Name $policyName ` + -ErrorAction SilentlyContinue + if ($null -ne $value -and [int]$value -eq 0) { + throw "Windows Package Manager is disabled by policy: $policyPath\$policyName = 0" + } + } + + $cpu = Get-CimInstance -ClassName Win32_Processor -ErrorAction SilentlyContinue | + Select-Object -First 1 + $computer = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue + if ($cpu -and $computer -and + -not $cpu.VirtualizationFirmwareEnabled -and + -not $computer.HypervisorPresent) { + Write-SlipstreamLog ` + -RunId $State.runId ` + -Level WARN ` + -Message 'Hardware virtualization was not reported as enabled. WSL may require a BIOS/VM-host change.' + } + + $payload = Test-SlipstreamPayload ` + -PayloadRoot $State.payloadRoot ` + -AllowUnsigned:$AllowUnsigned + Write-SlipstreamLog ` + -RunId $State.runId ` + -Message "Preflight passed: Windows build $($os.BuildNumber), ${freeGb} GB free, $($payload.Packages) packages, $($payload.RegistryValues) registry values." + + return New-SlipstreamPhaseResult +} + +function Repair-SlipstreamWinGet { + param([Parameter(Mandatory)] [object] $State) + + Write-SlipstreamLog ` + -RunId $State.runId ` + -Level WARN ` + -Message 'WinGet is missing or too old. Installing the latest signed App Installer release.' + + Install-SlipstreamWinGetRelease -State $State + + Start-Sleep -Seconds 3 + Refresh-SlipstreamPath + $version = Get-SlipstreamWinGetVersion + if (-not $version) { + throw 'WinGet repair completed, but winget.exe is still unavailable for the interactive user.' + } + Write-SlipstreamLog -RunId $State.runId -Message "WinGet repaired successfully: $version" +} + +function Invoke-SlipstreamPlatformRepair { + param([Parameter(Mandatory)] [object] $State) + + $minimumVersion = [version]'1.6.0' + $version = Get-SlipstreamWinGetVersion + if (-not $version -or $version -lt $minimumVersion) { + Repair-SlipstreamWinGet -State $State + $version = Get-SlipstreamWinGetVersion + } + + if (-not $version -or $version -lt $minimumVersion) { + throw "WinGet $minimumVersion or later is required. Found: $version" + } + + Write-SlipstreamLog -RunId $State.runId -Message "WinGet ready: $version" + $winget = Get-SlipstreamWinGetCommand + $sourceResult = Invoke-SlipstreamNative ` + -RunId $State.runId ` + -FilePath $winget ` + -ArgumentList @('source', 'update', '--name', 'winget', '--disable-interactivity') ` + -Name 'Update WinGet sources' ` + -MaxAttempts 3 ` + -AllowAnyExitCode + if ($sourceResult.ExitCode -ne 0) { + Write-SlipstreamLog ` + -RunId $State.runId ` + -Level WARN ` + -Message "WinGet source update failed with $($sourceResult.ExitCodeHex); checking the cached source." + $probe = Invoke-SlipstreamNative ` + -RunId $State.runId ` + -FilePath $winget ` + -ArgumentList @( + 'show', '--id', 'Git.Git', '--exact', '--source', 'winget', + '--disable-interactivity', '--accept-source-agreements' + ) ` + -Name 'Probe cached WinGet source' ` + -AllowAnyExitCode + if ($probe.ExitCode -ne 0) { + throw "WinGet source is unusable after update failure: $($probe.ExitCodeHex)" + } + } + + return New-SlipstreamPhaseResult +} + +function Invoke-SlipstreamPendingRebootGate { + param([Parameter(Mandatory)] [object] $State) + + $reasons = @(Get-SlipstreamPendingRebootReasons) + if ($reasons.Count -eq 0) { + Write-SlipstreamLog -RunId $State.runId -Message 'No pre-existing pending restart detected.' + return New-SlipstreamPhaseResult + } + + $priorPendingRestart = @($State.rebootHistory) | + Where-Object { $_ -like 'PendingReboot:*' } | + Select-Object -First 1 + if ($priorPendingRestart) { + Write-SlipstreamLog ` + -RunId $State.runId ` + -Level WARN ` + -Message "Pending restart markers remain after a setup restart; treating them as stale: $($reasons -join ', ')" + return New-SlipstreamPhaseResult + } + + return New-SlipstreamPhaseResult ` + -RebootRequired ` + -Reason ('PendingReboot:' + ($reasons -join ',')) +} + +function Test-SlipstreamWslPlatformReady { + try { + $wsl = Get-WindowsOptionalFeature ` + -Online ` + -FeatureName Microsoft-Windows-Subsystem-Linux ` + -ErrorAction Stop + $vmp = Get-WindowsOptionalFeature ` + -Online ` + -FeatureName VirtualMachinePlatform ` + -ErrorAction Stop + $vmcompute = Get-CimInstance ` + -ClassName Win32_Service ` + -Filter "Name='vmcompute'" ` + -ErrorAction SilentlyContinue + return $wsl.State -eq 'Enabled' -and + $vmp.State -eq 'Enabled' -and + [bool]$vmcompute + } + catch { + return $false + } +} + +function Invoke-SlipstreamWslPlatform { + param([Parameter(Mandatory)] [object] $State) + + if (Test-SlipstreamWslPlatformReady) { + Write-SlipstreamLog -RunId $State.runId -Message 'WSL platform features are already active.' + return New-SlipstreamPhaseResult + } + + $wslPath = Join-Path $env:SystemRoot 'System32\wsl.exe' + $result = Invoke-SlipstreamNative ` + -RunId $State.runId ` + -FilePath $wslPath ` + -ArgumentList @('--install', '--no-distribution') ` + -Name 'Enable WSL platform' ` + -AllowAnyExitCode + + if ($result.ExitCode -notin @(0, 3010, 1641)) { + throw "wsl --install --no-distribution failed: $($result.ExitCodeHex)" + } + + if ($result.ExitCode -in @(3010, 1641) -or -not (Test-SlipstreamWslPlatformReady)) { + return New-SlipstreamPhaseResult ` + -RebootRequired ` + -Reason 'EnableWslPlatform' + } + + return New-SlipstreamPhaseResult +} + +function Get-SlipstreamWslDistros { + $previousUtf8 = $env:WSL_UTF8 + try { + $env:WSL_UTF8 = '1' + $output = & (Join-Path $env:SystemRoot 'System32\wsl.exe') --list --quiet 2>$null + if ($LASTEXITCODE -ne 0) { + return @() + } + return @($output | + ForEach-Object { ($_ -replace "`0", '').Trim() } | + Where-Object { $_ }) + } + finally { + $env:WSL_UTF8 = $previousUtf8 + } +} + +function Invoke-SlipstreamWslDistro { + param([Parameter(Mandatory)] [object] $State) + + $distroName = 'Ubuntu' + if (@(Get-SlipstreamWslDistros) -contains $distroName) { + Write-SlipstreamLog -RunId $State.runId -Message "$distroName is already registered with WSL." + return New-SlipstreamPhaseResult + } + + $lxssPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' + New-Item -Path $lxssPath -Force | Out-Null + New-ItemProperty ` + -Path $lxssPath ` + -Name OOBEComplete ` + -Value 1 ` + -PropertyType DWord ` + -Force | Out-Null + + $result = Invoke-SlipstreamNative ` + -RunId $State.runId ` + -FilePath (Join-Path $env:SystemRoot 'System32\wsl.exe') ` + -ArgumentList @('--install', '--distribution', $distroName, '--no-launch') ` + -Name "Install WSL distro $distroName" ` + -MaxAttempts 3 ` + -AllowAnyExitCode + + if ($result.ExitCode -in @(3010, 1641)) { + return New-SlipstreamPhaseResult ` + -RebootRequired ` + -AdvancePhase:$false ` + -Reason 'InstallWslDistro' + } + if ($result.ExitCode -ne 0) { + throw "WSL distro installation failed: $($result.ExitCodeHex)" + } + + Start-Sleep -Seconds 2 + if (@(Get-SlipstreamWslDistros) -notcontains $distroName) { + throw "$distroName installation returned success but the distro is not registered." + } + return New-SlipstreamPhaseResult +} diff --git a/src/windows-dev-config/bootstrap/resume.ps1 b/src/windows-dev-config/bootstrap/resume.ps1 new file mode 100644 index 0000000..d6026f7 --- /dev/null +++ b/src/windows-dev-config/bootstrap/resume.ps1 @@ -0,0 +1,231 @@ +Set-StrictMode -Version Latest + +function Get-SlipstreamTaskName { + param([Parameter(Mandatory)] [string] $RunId) + + return "WindowsDeveloperConfig-Slipstream-$RunId" +} + +function Get-SlipstreamUserTaskName { + param([Parameter(Mandatory)] [string] $RunId) + + return "WindowsDeveloperConfig-User-$RunId" +} + +function New-SlipstreamResumeTaskDefinition { + param( + [Parameter(Mandatory)] [object] $State, + [switch] $AllowUnsigned, + [switch] $NoRestart + ) + + $powershellPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $installPath = Join-Path $State.payloadRoot 'install.ps1' + $arguments = @( + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', $(if ($AllowUnsigned) { 'Bypass' } else { 'AllSigned' }), + '-File', ('"{0}"' -f $installPath), + '-Action', 'Resume', + '-RunId', ('"{0}"' -f $State.runId), + '-PayloadRoot', ('"{0}"' -f $State.payloadRoot) + ) + if ($AllowUnsigned) { + $arguments += '-AllowUnsigned' + } + if ($NoRestart) { + $arguments += '-NoRestart' + } + + $action = New-ScheduledTaskAction ` + -Execute $powershellPath ` + -Argument ($arguments -join ' ') ` + -WorkingDirectory $State.payloadRoot + $trigger = New-ScheduledTaskTrigger -AtLogOn -User $State.originalUserSid + $principal = New-ScheduledTaskPrincipal ` + -UserId $State.originalUserSid ` + -LogonType Interactive ` + -RunLevel Highest + $settings = New-ScheduledTaskSettingsSet ` + -StartWhenAvailable ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Hours 12) ` + -MultipleInstances IgnoreNew ` + -RestartCount 3 ` + -RestartInterval (New-TimeSpan -Minutes 2) + + return New-ScheduledTask ` + -Action $action ` + -Trigger $trigger ` + -Principal $principal ` + -Settings $settings ` + -Description 'Resume Windows Developer Config after a required restart.' +} + +function Register-SlipstreamResumeTask { + param( + [Parameter(Mandatory)] [object] $State, + [switch] $AllowUnsigned, + [switch] $NoRestart + ) + + $taskName = Get-SlipstreamTaskName -RunId $State.runId + $definition = New-SlipstreamResumeTaskDefinition ` + -State $State ` + -AllowUnsigned:$AllowUnsigned ` + -NoRestart:$NoRestart + Register-ScheduledTask ` + -TaskName $taskName ` + -InputObject $definition ` + -Force | Out-Null + Write-SlipstreamLog -RunId $State.runId -Message "Registered elevated resume task '$taskName'." +} + +function New-SlipstreamUserTaskDefinition { + param( + [Parameter(Mandatory)] [object] $State, + [ValidateSet('Configure', 'Verify')] + [string] $Mode = 'Configure', + [switch] $AllowUnsigned + ) + + $powershellPath = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' + $userScriptPath = Join-Path $State.payloadRoot 'bootstrap\user.ps1' + $executionPolicy = if ($AllowUnsigned) { 'Bypass' } else { 'AllSigned' } + $arguments = @( + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', $executionPolicy, + '-File', ('"{0}"' -f $userScriptPath), + '-Action', $Mode + ) + $taskAction = New-ScheduledTaskAction ` + -Execute $powershellPath ` + -Argument ($arguments -join ' ') ` + -WorkingDirectory $State.payloadRoot + $principal = New-ScheduledTaskPrincipal ` + -UserId $State.originalUserSid ` + -LogonType Interactive ` + -RunLevel Limited + $settings = New-ScheduledTaskSettingsSet ` + -AllowStartIfOnBatteries ` + -DontStopIfGoingOnBatteries ` + -ExecutionTimeLimit (New-TimeSpan -Minutes 15) ` + -MultipleInstances IgnoreNew + + return New-ScheduledTask ` + -Action $taskAction ` + -Principal $principal ` + -Settings $settings ` + -Description 'Apply Windows Developer Config per-user settings without elevation.' +} + +function Invoke-SlipstreamUserConfiguration { + param( + [Parameter(Mandatory)] [object] $State, + [ValidateSet('Configure', 'Verify')] + [string] $Mode = 'Configure', + [switch] $AllowUnsigned + ) + + $taskName = Get-SlipstreamUserTaskName -RunId $State.runId + $definition = New-SlipstreamUserTaskDefinition ` + -State $State ` + -Mode $Mode ` + -AllowUnsigned:$AllowUnsigned + try { + Register-ScheduledTask ` + -TaskName $taskName ` + -InputObject $definition ` + -Force | Out-Null + $previousRun = (Get-ScheduledTaskInfo -TaskName $taskName).LastRunTime + Start-ScheduledTask -TaskName $taskName + + $deadline = [DateTime]::UtcNow.AddMinutes(15) + $started = $false + do { + Start-Sleep -Seconds 1 + $task = Get-ScheduledTask -TaskName $taskName -ErrorAction Stop + $info = Get-ScheduledTaskInfo -TaskName $taskName -ErrorAction Stop + $started = $info.LastRunTime -gt $previousRun + if ($started -and $task.State -ne 'Running') { + break + } + } while ([DateTime]::UtcNow -lt $deadline) + + if (-not $started -or $task.State -eq 'Running') { + throw 'Timed out waiting for the limited per-user configuration task.' + } + if ([int]$info.LastTaskResult -ne 0) { + throw "Per-user configuration failed with task result $($info.LastTaskResult)." + } + Write-SlipstreamLog ` + -RunId $State.runId ` + -Message "Completed limited-token Copilot plugin $($Mode.ToLowerInvariant())." + } + finally { + $existing = Get-ScheduledTask ` + -TaskName $taskName ` + -ErrorAction SilentlyContinue + if ($existing) { + if ($existing.State -eq 'Running') { + Stop-ScheduledTask -TaskName $taskName + } + Unregister-ScheduledTask ` + -TaskName $taskName ` + -Confirm:$false + } + } +} + +function Unregister-SlipstreamResumeTask { + param([Parameter(Mandatory)] [string] $RunId) + + $taskName = Get-SlipstreamTaskName -RunId $RunId + $existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue + if ($existing) { + Unregister-ScheduledTask -TaskName $taskName -Confirm:$false + Write-SlipstreamLog -RunId $RunId -Message "Removed resume task '$taskName'." + } +} + +function Request-SlipstreamRestart { + param( + [Parameter(Mandatory)] [object] $State, + [Parameter(Mandatory)] [string] $Reason, + [switch] $NoRestart + ) + + if ([int]$State.rebootCount -ge 3) { + throw "Refusing another restart after $($State.rebootCount) setup restarts. Last reason: $Reason" + } + + $State.status = 'WaitingForReboot' + $State.rebootCount = [int]$State.rebootCount + 1 + $State.rebootBootId = Get-SlipstreamBootId + $State.rebootReason = $Reason + $State.rebootHistory = @($State.rebootHistory) + $Reason + Save-SlipstreamState -State $State + + if ($NoRestart) { + Write-SlipstreamLog ` + -RunId $State.runId ` + -Level WARN ` + -Message "Restart required but -NoRestart was supplied. Reason: $Reason" + return + } + + Write-SlipstreamLog ` + -RunId $State.runId ` + -Message "Restarting in 15 seconds. Setup will resume after sign-in. Reason: $Reason" + $result = Invoke-SlipstreamNative ` + -RunId $State.runId ` + -FilePath (Join-Path $env:SystemRoot 'System32\shutdown.exe') ` + -ArgumentList @('/r', '/t', '15', '/d', 'p:4:1', '/c', 'Windows Developer Config will resume after sign-in.') ` + -Name 'Schedule Windows restart' ` + -AllowAnyExitCode + if ($result.ExitCode -ne 0) { + throw "Failed to schedule restart: $($result.ExitCodeHex)" + } +} diff --git a/src/windows-dev-config/bootstrap/user.ps1 b/src/windows-dev-config/bootstrap/user.ps1 new file mode 100644 index 0000000..e7d9d2a --- /dev/null +++ b/src/windows-dev-config/bootstrap/user.ps1 @@ -0,0 +1,80 @@ +[CmdletBinding()] +param( + [ValidateSet('Configure', 'Verify')] + [string] $Action = 'Configure' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +. (Join-Path $PSScriptRoot 'common.ps1') + +function Invoke-SlipstreamUserCopilot { + param( + [Parameter(Mandatory)] [string] $FilePath, + [Parameter(Mandatory)] [string[]] $ArgumentList, + [Parameter(Mandatory)] [string] $Operation + ) + + $output = @(& $FilePath @ArgumentList 2>&1) + if ($LASTEXITCODE -ne 0) { + throw "$Operation failed with exit code $LASTEXITCODE`: $($output -join [Environment]::NewLine)" + } + return $output +} + +try { + $identity = [Security.Principal.WindowsIdentity]::GetCurrent() + $principal = [Security.Principal.WindowsPrincipal]::new($identity) + if ($principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Per-user configuration must run with a limited token.' + } + + Refresh-SlipstreamPath + $copilot = Get-SlipstreamSignedCommand ` + -Name copilot.exe ` + -PublisherPattern 'O="?GitHub, Inc\.?"?' + + $marketplaces = Invoke-SlipstreamUserCopilot ` + -FilePath $copilot ` + -ArgumentList @('plugin', 'marketplace', 'list') ` + -Operation 'Query Copilot plugin marketplaces' + if (($marketplaces -join "`n") -notmatch '(?i)win-dev-skills') { + if ($Action -eq 'Verify') { + throw 'Win Dev Skills marketplace is not configured.' + } + Invoke-SlipstreamUserCopilot ` + -FilePath $copilot ` + -ArgumentList @( + 'plugin', 'marketplace', 'add', 'microsoft/win-dev-skills' + ) ` + -Operation 'Add Win Dev Skills marketplace' | Out-Null + } + + $plugins = Invoke-SlipstreamUserCopilot ` + -FilePath $copilot ` + -ArgumentList @('plugin', 'list') ` + -Operation 'Query Copilot plugins' + if (($plugins -join "`n") -notmatch '(?i)\bwinui\b') { + if ($Action -eq 'Verify') { + throw 'WinUI Copilot plugin is not installed.' + } + Invoke-SlipstreamUserCopilot ` + -FilePath $copilot ` + -ArgumentList @('plugin', 'install', 'winui@win-dev-skills') ` + -Operation 'Install WinUI Copilot plugin' | Out-Null + } + + $plugins = Invoke-SlipstreamUserCopilot ` + -FilePath $copilot ` + -ArgumentList @('plugin', 'list') ` + -Operation 'Verify Copilot plugins' + if (($plugins -join "`n") -notmatch '(?i)\bwinui\b') { + throw 'WinUI Copilot plugin was not present after installation.' + } + exit 0 +} +catch { + [Console]::Error.WriteLine($_.Exception.Message) + exit 1 +} diff --git a/src/windows-dev-config/bootstrap/verify.ps1 b/src/windows-dev-config/bootstrap/verify.ps1 new file mode 100644 index 0000000..d1dfd8c --- /dev/null +++ b/src/windows-dev-config/bootstrap/verify.ps1 @@ -0,0 +1,142 @@ +Set-StrictMode -Version Latest + +function Test-SlipstreamTerminalConfiguration { + $settingsPath = Get-SlipstreamTerminalSettingsPath + if (-not (Test-Path -LiteralPath $settingsPath)) { + return $false + } + + try { + $raw = Get-Content -LiteralPath $settingsPath -Raw -Encoding UTF8 + $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') + $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') + $settings = $clean | ConvertFrom-Json + return $settings.profiles.defaults.font.face -eq 'Cascadia Mono NF' -and + $settings.defaultProfile -eq '{574e775e-4f2a-5b96-ac1e-a2962a402336}' + } + catch { + return $false + } +} + +function Test-SlipstreamOhMyPoshProfile { + try { + $pwsh = Get-SlipstreamSignedCommand ` + -Name pwsh.exe ` + -PublisherPattern 'O=Microsoft Corporation' + $profileOutput = & $pwsh -NoLogo -NoProfile -Command '$PROFILE' 2>$null | + Select-Object -First 1 + if ($LASTEXITCODE -ne 0 -or -not $profileOutput) { + return $false + } + $profilePath = $profileOutput.ToString().Trim() + return (Test-Path -LiteralPath $profilePath) -and + [bool](Select-String ` + -LiteralPath $profilePath ` + -SimpleMatch '# Windows Developer Config: Oh My Posh' ` + -Quiet) + } + catch { + return $false + } +} + +function Invoke-SlipstreamVerification { + param( + [Parameter(Mandatory)] [object] $State, + [switch] $AllowUnsigned + ) + + Refresh-SlipstreamPath + $failures = [System.Collections.Generic.List[string]]::new() + $winget = Get-SlipstreamWinGetCommand + if (-not $winget) { + $failures.Add('winget.exe is unavailable') + } + else { + $packages = Get-SlipstreamPackageManifest -PayloadRoot $State.payloadRoot + foreach ($package in $packages.packages) { + if (-not (Test-SlipstreamPackageInstalled -WinGetPath $winget -Id $package.id)) { + $failures.Add("package missing: $($package.id)") + } + foreach ($command in @($package.verifyCommands)) { + if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { + $failures.Add("command missing: $command [$($package.id)]") + } + } + } + } + + $registry = Get-SlipstreamRegistryManifest -PayloadRoot $State.payloadRoot + foreach ($entry in $registry.values) { + if (-not (Test-SlipstreamRegistryValue -Entry $entry)) { + $failures.Add("registry mismatch: $($entry.name)") + } + } + + if (-not (Test-SlipstreamWslPlatformReady)) { + $failures.Add('WSL platform features are not active') + } + if (@(Get-SlipstreamWslDistros) -notcontains 'Ubuntu') { + $failures.Add('Ubuntu is not registered with WSL') + } + if (-not (Test-SlipstreamCascadiaFonts)) { + $failures.Add('Cascadia Nerd Fonts are not registered') + } + if (-not (Test-SlipstreamTerminalConfiguration)) { + $failures.Add('Windows Terminal defaults are not configured') + } + if (-not (Test-SlipstreamOhMyPoshProfile)) { + $failures.Add('Oh My Posh is not configured in the PowerShell 7 profile') + } + + try { + $dotnet = Get-SlipstreamSignedCommand ` + -Name dotnet.exe ` + -PublisherPattern 'O=Microsoft Corporation' + } + catch { + $dotnet = $null + } + $templateOutput = if ($dotnet) { + & $dotnet new list 2>&1 + } + else { + @() + } + if (-not $dotnet -or $LASTEXITCODE -ne 0 -or ($templateOutput -join "`n") -notmatch '(?i)winui') { + $failures.Add('WinUI .NET templates are not installed') + } + + try { + Invoke-SlipstreamUserConfiguration ` + -State $State ` + -Mode Verify ` + -AllowUnsigned:$AllowUnsigned + } + catch { + $failures.Add("limited-token Copilot verification failed: $($_.Exception.Message)") + } + + if ($failures.Count -gt 0) { + throw "Final verification failed:`n - $($failures -join "`n - ")" + } + + $pendingReasons = @(Get-SlipstreamPendingRebootReasons) + $alreadyRestarted = @($State.rebootHistory) -contains 'FinalVerification' + if ($pendingReasons.Count -gt 0 -and -not $alreadyRestarted) { + return New-SlipstreamPhaseResult ` + -RebootRequired ` + -AdvancePhase:$false ` + -Reason 'FinalVerification' + } + if ($pendingReasons.Count -gt 0) { + Write-SlipstreamLog ` + -RunId $State.runId ` + -Level WARN ` + -Message "Restart markers remain after final restart and may be stale: $($pendingReasons -join ', ')" + } + + Write-SlipstreamLog -RunId $State.runId -Message 'Final verification passed.' + return New-SlipstreamPhaseResult +} diff --git a/src/windows-dev-config/config/packages.json b/src/windows-dev-config/config/packages.json new file mode 100644 index 0000000..bdaf914 --- /dev/null +++ b/src/windows-dev-config/config/packages.json @@ -0,0 +1,95 @@ +{ + "schemaVersion": 1, + "packages": [ + { + "name": "Windows Terminal", + "id": "Microsoft.WindowsTerminal", + "source": "winget", + "verifyCommands": ["wt.exe"] + }, + { + "name": "PowerShell 7", + "id": "Microsoft.PowerShell", + "source": "winget", + "verifyCommands": ["pwsh.exe"] + }, + { + "name": "Git", + "id": "Git.Git", + "source": "winget", + "verifyCommands": ["git.exe"] + }, + { + "name": "GitHub CLI", + "id": "GitHub.Cli", + "source": "winget", + "verifyCommands": ["gh.exe"] + }, + { + "name": "GitHub Copilot CLI", + "id": "GitHub.Copilot", + "source": "winget", + "verifyCommands": ["copilot.exe"] + }, + { + "name": "Visual Studio Code", + "id": "Microsoft.VisualStudioCode", + "source": "winget", + "verifyCommands": ["code.cmd"] + }, + { + "name": ".NET 10 SDK", + "id": "Microsoft.dotnet.SDK.10", + "source": "winget", + "verifyCommands": ["dotnet.exe"] + }, + { + "name": "Python 3.14", + "id": "Python.Python.3.14", + "source": "winget", + "verifyCommands": ["python.exe"] + }, + { + "name": "uv", + "id": "astral-sh.uv", + "source": "winget", + "verifyCommands": ["uv.exe"] + }, + { + "name": "Node.js LTS", + "id": "OpenJS.NodeJS.LTS", + "source": "winget", + "verifyCommands": ["node.exe", "npm.cmd"] + }, + { + "name": "NVM for Windows", + "id": "CoreyButler.NVMforWindows", + "source": "winget", + "verifyCommands": ["nvm.exe"] + }, + { + "name": "Coreutils for Windows", + "id": "Microsoft.Coreutils", + "source": "winget", + "verifyCommands": [] + }, + { + "name": "Oh My Posh", + "id": "JanDeDobbeleer.OhMyPosh", + "source": "winget", + "verifyCommands": ["oh-my-posh.exe"] + }, + { + "name": "Windows Application CLI", + "id": "Microsoft.WinAppCli", + "source": "winget", + "verifyCommands": ["winapp.exe"] + }, + { + "name": "PowerToys", + "id": "Microsoft.PowerToys", + "source": "winget", + "verifyCommands": [] + } + ] +} diff --git a/src/windows-dev-config/config/registry.json b/src/windows-dev-config/config/registry.json new file mode 100644 index 0000000..652569b --- /dev/null +++ b/src/windows-dev-config/config/registry.json @@ -0,0 +1,187 @@ +{ + "schemaVersion": 1, + "values": [ + { + "name": "Dark apps", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "valueName": "AppsUseLightTheme", + "type": "DWord", + "value": 0 + }, + { + "name": "Dark system", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + "valueName": "SystemUsesLightTheme", + "type": "DWord", + "value": 0 + }, + { + "name": "Windows Sudo", + "path": "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Sudo", + "valueName": "Enabled", + "type": "DWord", + "value": 3 + }, + { + "name": "Developer Mode", + "path": "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\AppModelUnlock", + "valueName": "AllowDevelopmentWithoutDevLicense", + "type": "DWord", + "value": 1 + }, + { + "name": "Long paths", + "path": "HKLM\\SYSTEM\\CurrentControlSet\\Control\\FileSystem", + "valueName": "LongPathsEnabled", + "type": "DWord", + "value": 1 + }, + { + "name": "Remote Desktop", + "path": "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Terminal Server", + "valueName": "fDenyTSConnections", + "type": "DWord", + "value": 0 + }, + { + "name": "Show file extensions", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "HideFileExt", + "type": "DWord", + "value": 0 + }, + { + "name": "Show hidden files", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "Hidden", + "type": "DWord", + "value": 1 + }, + { + "name": "Show full Explorer path", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "FullPathAddress", + "type": "DWord", + "value": 1 + }, + { + "name": "Open Explorer to This PC", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "LaunchTo", + "type": "DWord", + "value": 1 + }, + { + "name": "Hide frequent folders", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "ShowFrequent", + "type": "DWord", + "value": 0 + }, + { + "name": "Hide recent files", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer", + "valueName": "ShowRecent", + "type": "DWord", + "value": 0 + }, + { + "name": "Hide cloud recommendations", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer", + "valueName": "ShowCloudFilesInQuickAccess", + "type": "DWord", + "value": 0 + }, + { + "name": "Show Git folders", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "NavPaneShowVersionControl", + "type": "DWord", + "value": 1 + }, + { + "name": "Disable Explorer tips", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "ShowSyncProviderNotifications", + "type": "DWord", + "value": 0 + }, + { + "name": "Disable toast notifications", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Notifications\\Settings", + "valueName": "NOC_GLOBAL_SETTING_TOASTS_ENABLED", + "type": "DWord", + "value": 0 + }, + { + "name": "Hide taskbar Widgets", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "TaskbarDa", + "type": "DWord", + "value": 0 + }, + { + "name": "Hide Bluetooth tray icon", + "path": "HKCU\\Control Panel\\Bluetooth", + "valueName": "Notification Area Icon", + "type": "DWord", + "value": 0 + }, + { + "name": "Enable taskbar End Task", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "TaskbarEndTask", + "type": "DWord", + "value": 1 + }, + { + "name": "Disable Start web search", + "path": "HKCU\\SOFTWARE\\Policies\\Microsoft\\Windows\\Explorer", + "valueName": "DisableSearchBoxSuggestions", + "type": "DWord", + "value": 1 + }, + { + "name": "Disable search highlights", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\SearchSettings", + "valueName": "IsDynamicSearchBoxEnabled", + "type": "DWord", + "value": 0 + }, + { + "name": "Disable Start recommendations", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Advanced", + "valueName": "Start_IrisRecommendations", + "type": "DWord", + "value": 0 + }, + { + "name": "Disable Widgets service", + "path": "HKLM\\SOFTWARE\\Policies\\Microsoft\\Dsh", + "valueName": "AllowNewsAndInterests", + "type": "DWord", + "value": 0 + }, + { + "name": "Blank Edge new tab", + "path": "HKLM\\SOFTWARE\\Policies\\Microsoft\\Edge", + "valueName": "NewTabPageLocation", + "type": "String", + "value": "about:blank" + }, + { + "name": "Hide Edge first-run experience", + "path": "HKLM\\SOFTWARE\\Policies\\Microsoft\\Edge", + "valueName": "HideFirstRunExperience", + "type": "DWord", + "value": 1 + }, + { + "name": "Disable PowerToys AOT notifications", + "path": "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Notifications\\Settings\\PowerToys", + "valueName": "Enabled", + "type": "DWord", + "value": 0 + } + ] +} diff --git a/src/windows-dev-config/dev-config.winget b/src/windows-dev-config/dev-config.winget deleted file mode 100644 index 33772be..0000000 --- a/src/windows-dev-config/dev-config.winget +++ /dev/null @@ -1,1056 +0,0 @@ -# Dev-Config — Developer Workstation Setup -# Apply with: winget configure -f dev-config.winget --accept-configuration-agreements --disable-interactivity - -$schema: https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/2023/08/config/document.json -metadata: - winget: - processor: - identifier: dscv3 -resources: - -# --------------------------------------------------------------------------- -# Phase 0 — Elevation check (runs on initial invoke AND post-reboot resume) -# --------------------------------------------------------------------------- -# -# - name: ElevationCheck -# type: Microsoft.DSC.Transitional/WindowsPowerShellScript -# properties: -# getScript: | -# $id = [Security.Principal.WindowsIdentity]::GetCurrent() -# $p = [Security.Principal.WindowsPrincipal] $id -# return @{ elevated = $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } -# testScript: | -# $id = [Security.Principal.WindowsIdentity]::GetCurrent() -# $p = [Security.Principal.WindowsPrincipal] $id -# return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) -# setScript: | -# $configFile = "${WinGetConfigRoot}\dev-config.winget" -# -# if (-not (Test-Path $configFile)) { -# $hint = "winget configure --file `"$configFile`" --accept-configuration-agreements --disable-interactivity" -# throw "ElevationCheck: not elevated and cannot locate config file to re-launch automatically. Please re-run in an elevated terminal: $hint" -# } -# -# Write-Host "ElevationCheck: not elevated — re-launching winget configure as Administrator..." -# -# $wingetArgs = @( -# 'configure', -# '--file', "`"$configFile`"", -# '--accept-configuration-agreements', -# '--disable-interactivity', -# '--wait' -# ) -# Start-Process winget -ArgumentList $wingetArgs -Verb RunAs -# -# throw "ElevationCheck: re-launched elevated successfully. This unelevated session is now complete — check the elevated window for results." - -# --------------------------------------------------------------------------- -# WSL Phase 1 — Enable WSL optional components via wsl --install -# (enables Virtual Machine Platform; reboot required) -# --------------------------------------------------------------------------- - -- name: InstallWslComponents - type: Microsoft.DSC.Transitional/WindowsPowerShellScript - properties: - getScript: | - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return @{ vmcomputePresent = [bool]$svc } - testScript: | - # If vmcompute is present, VMP is active — components already installed. - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return [bool]$svc - setScript: | - Write-Host "InstallWslComponents: running wsl --install --no-distribution..." - # Launch wsl.exe directly WITHOUT -NoNewWindow and WITHOUT -RedirectStandard* - # so Start-Process allocates a fresh console (CREATE_NEW_CONSOLE), which - # wsl's install bootstrap requires. The dscv3 host has no console to - # inherit, so -NoNewWindow (or -RedirectStandardOutput, which also - # suppresses the new console) makes wsl fail with "The Windows Subsystem - # for Linux is not installed". With no -Redirect* flags PowerShell never - # captures wsl's output — it goes to that console, not PowerShell's error - # stream — so there is no stderr-as-error problem and no cmd ">nul" needed. - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install','--no-distribution' -Wait -PassThru - if ($p.ExitCode -eq 3010 -or $p.ExitCode -eq 1641) { - Write-Host "WSL installed successfully, but a system reboot is required." - } - elseif ($p.ExitCode -ne 0) { - throw "InstallWslComponents: wsl --install failed with exit code $($p.ExitCode)" - } - metadata: - securityContext: elevated - -# --------------------------------------------------------------------------- -# WSL Phase 2 — Reboot so VMP takes effect -# --------------------------------------------------------------------------- - -- name: RebootForVmp - type: Microsoft.DSC.Transitional/WindowsPowerShellScript - dependsOn: - - InstallWslComponents - properties: - getScript: | - # Get-CimInstance returns $null without error when the service doesn't - # exist (unlike Get-Service, which sets HadErrors at the hosting layer - # even with -ErrorAction Ignore or try/catch). - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return @{ vmcomputePresent = [bool]$svc } - testScript: | - # vmcompute (Hyper-V Host Compute Service) is registered once Virtual - # Machine Platform is active post-reboot. Presence alone is sufficient - # — no need to check if it is running. - $svc = Get-CimInstance -ClassName Win32_Service -Filter "Name='vmcompute'" - return [bool]$svc - setScript: | - # ${WinGetConfigRoot} is set by winget configure to the directory - # containing the config file being applied. - $configFile = "${WinGetConfigRoot}\dev-config.winget" - $resumeCmd = "winget configure --file `"$configFile`" --accept-configuration-agreements" - - # RunOnce entries are deleted by Windows automatically before they - # are executed, so no cleanup step is needed in this config. - $runOncePath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce' - Set-ItemProperty -Path $runOncePath -Name 'DSCConfigureResume' -Value $resumeCmd -Force - - Write-Host "RebootForVmp: registered RunOnce resume key." - Write-Host " HKCU:\...\RunOnce\DSCConfigureResume = $resumeCmd" - Write-Host "RebootForVmp: rebooting now to activate Virtual Machine Platform..." - Restart-Computer -Force - - # Restart-Computer -Force returns immediately after signalling the OS - # to reboot — it does not block until the machine goes down. Without - # an explicit failure here DSC would consider this resource succeeded - # and attempt to run InstallUbuntu before the reboot happens. - # Throwing forces DSC to mark the current run as failed; the RunOnce - # key handles resuming on the next login. - Start-Sleep -Seconds 60 # give the OS time to initiate shutdown - throw "Reboot initiated to activate Virtual Machine Platform. DSC will resume via RunOnce on next login." - metadata: - securityContext: elevated - - -# --------------------------------------------------------------------------- -# WSL Phase 3 — Install default Ubuntu distro (VMP now active post-reboot) -# --------------------------------------------------------------------------- - -- name: InstallUbuntu - type: Microsoft.DSC.Transitional/WindowsPowerShellScript - dependsOn: - - RebootForVmp - properties: - getScript: | - # Run wsl --list via Start-Process with redirected output. Calling wsl.exe - # directly leaks its non-zero exit code (when no distro is installed) into - # $LASTEXITCODE and routes its stderr into PowerShell's error stream — both - # of which the dscv3 PowerShell adapter treats as a resource failure. - # Start-Process isolates the native call: stderr never reaches the error - # stream and $LASTEXITCODE is untouched. - $env:WSL_UTF8 = '1' - $distros = @() - $out = [System.IO.Path]::GetTempFileName() - $err = [System.IO.Path]::GetTempFileName() - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list','--quiet' ` - -NoNewWindow -Wait -PassThru ` - -RedirectStandardOutput $out -RedirectStandardError $err - if ($p.ExitCode -eq 0) { - $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | - ForEach-Object { ($_ -replace "`0", '').Trim() } | - Where-Object { $_ }) - } - Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue - return @{ distroCount = $distros.Count; distros = ($distros -join ',') } - testScript: | - $env:WSL_UTF8 = '1' - $out = [System.IO.Path]::GetTempFileName() - $err = [System.IO.Path]::GetTempFileName() - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--list','--quiet' ` - -NoNewWindow -Wait -PassThru ` - -RedirectStandardOutput $out -RedirectStandardError $err - if ($p.ExitCode -ne 0) { - Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue - return $false - } - $distros = @(Get-Content -LiteralPath $out -Encoding UTF8 | - ForEach-Object { ($_ -replace "`0", '').Trim() } | - Where-Object { $_ }) - Remove-Item -LiteralPath $out, $err -Force -ErrorAction SilentlyContinue - return $distros.Count -gt 0 - setScript: | - # Suppress the "Welcome to WSL" first-run GUI/OOBE - $lxssPath = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss' - New-Item -Path $lxssPath -Force | Out-Null - Set-ItemProperty -Path $lxssPath -Name 'OOBEComplete' -Value 1 -Type DWord -Force - - Write-Host "InstallUbuntu: running wsl --install -d Ubuntu --no-launch..." - # Launch wsl.exe directly WITHOUT -NoNewWindow and WITHOUT -RedirectStandard* - # so Start-Process allocates a fresh console (CREATE_NEW_CONSOLE), which - # wsl's install bootstrap requires. The dscv3 host has no console to - # inherit, so -NoNewWindow (or -RedirectStandardOutput, which also - # suppresses the new console) makes wsl fail with "The Windows Subsystem - # for Linux is not installed". With no -Redirect* flags PowerShell never - # captures wsl's output — it goes to that console, not PowerShell's error - # stream — so there is no stderr-as-error problem and no cmd ">nul" needed. - $p = Start-Process -FilePath 'wsl.exe' -ArgumentList '--install','-d','Ubuntu','--no-launch' -Wait -PassThru - if ($p.ExitCode -ne 0) { - throw "InstallUbuntu: wsl --install -d Ubuntu --no-launch failed with exit code $($p.ExitCode)" - } - metadata: - securityContext: elevated - -# ============================================================================= -# Terminal and PowerShell 7 -# ============================================================================= - -- type: Microsoft.WinGet/Package - name: Terminal - properties: - id: Microsoft.WindowsTerminal - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Windows Terminal - -- type: Microsoft.WinGet/Package - name: PowerShell - properties: - id: Microsoft.PowerShell - source: winget - useLatest: true - installMode: silent - metadata: - description: Install PowerShell 7 - -# ============================================================================= -# Force dark theme -# Uses app and system theme registry values to detect dark theme. -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: darkTheme - dependsOn: - - PowerShell - properties: - getScript: | - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -EA SilentlyContinue - $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -EA SilentlyContinue - return @{ AppsUseLightTheme = [int]$apps; SystemUsesLightTheme = [int]$system } - testScript: | - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - $apps = Get-ItemPropertyValue $regPath -Name AppsUseLightTheme -EA SilentlyContinue - $system = Get-ItemPropertyValue $regPath -Name SystemUsesLightTheme -EA SilentlyContinue - return ($apps -eq 0 -and $system -eq 0) - setScript: | - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize' - Set-ItemProperty -Path $regPath -Name "AppsUseLightTheme" -Value 0 - Set-ItemProperty -Path $regPath -Name "SystemUsesLightTheme" -Value 0 - metadata: - description: Sets dark theme - -# ============================================================================= -# Install Cascadia Code Nerd Fonts -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: InstallCascadiaCodeNerdFonts - dependsOn: - - PowerShell - properties: - getScript: | - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $wantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - $regValues = @( - (Get-ItemProperty $regPath -EA SilentlyContinue).PSObject.Properties | - Where-Object Name -notin 'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider' | - Select-Object -ExpandProperty Value - ) - $filesOk = -not ($wantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) - $regOk = -not ($wantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) - return @{ filesInstalled = $filesOk; registryEntries = $regOk } - testScript: | - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $wantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - $regValues = @( - (Get-ItemProperty $regPath -EA SilentlyContinue).PSObject.Properties | - Where-Object Name -notin 'PSPath','PSParentPath','PSChildName','PSDrive','PSProvider' | - Select-Object -ExpandProperty Value - ) - $filesOk = -not ($wantedFonts | Where-Object { -not (Test-Path (Join-Path $fontsDir $_)) }) - $regOk = -not ($wantedFonts | Where-Object { $fn = $_; -not ($regValues | Where-Object { $_ -like "*\$fn" }) }) - return ($filesOk -and $regOk) - setScript: | - $ErrorActionPreference = 'Stop' - - $Version = '2407.24' - $WantedFonts = @('CascadiaCodeNF.ttf', 'CascadiaMonoNF.ttf') - $zipUrl = "https://github.com/microsoft/cascadia-code/releases/download/v$Version/CascadiaCode-$Version.zip" - $workDir = Join-Path $env:TEMP "CascadiaCode-$Version" - $zipPath = Join-Path $workDir 'CascadiaCode.zip' - New-Item -ItemType Directory -Path $workDir -Force | Out-Null - - $fontsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' - $regPath = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' - New-Item -ItemType Directory -Path $fontsDir -Force | Out-Null - - Write-Host "Downloading $zipUrl ..." - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing - - $expectedHash = 'E67A68EE3386DB63F48B9054BD196EA752BC6A4EBB4DF35ADCE6733DA50C8474' - $actualHash = (Get-FileHash $zipPath -Algorithm SHA256).Hash - if ($actualHash -ne $expectedHash) { - Remove-Item $zipPath -Force - throw "Hash mismatch for CascadiaCode-$Version.zip: expected $expectedHash, got $actualHash" - } - - Add-Type -AssemblyName System.IO.Compression.FileSystem - Add-Type -AssemblyName System.Drawing - - $zip = [System.IO.Compression.ZipFile]::OpenRead($zipPath) - try { - foreach ($name in $WantedFonts) { - $entry = $zip.Entries | Where-Object { $_.Name -eq $name } | Select-Object -First 1 - if (-not $entry) { Write-Warning "Not found in archive: $name"; continue } - - $dest = Join-Path $fontsDir $name - Write-Host "Installing $name -> $dest" - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $dest, $true) - - $pfc = New-Object System.Drawing.Text.PrivateFontCollection - try { - $pfc.AddFontFile($dest) - $family = $pfc.Families[0].Name - } finally { $pfc.Dispose() } - - $regName = "$family (TrueType)" - New-ItemProperty -Path $regPath -Name $regName -Value $dest -PropertyType String -Force | Out-Null - Write-Host " registered as '$regName'" - } - } - finally { - $zip.Dispose() - } - - Remove-Item $zipPath -Force - Write-Host "`nDone. Restart any running apps (terminal, editors) to pick up the new fonts." - metadata: - description: Install Cascadia Code Nerd Fonts - -# ============================================================================= -# Set Cascadia Mono NF as default Windows Terminal font -# NOTE: This cannot use a fragment. Fragments support adding profiles and color -# schemes, but not profiles.defaults (which applies settings across all profiles). -# Direct settings.json modification is the only available approach here. -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: SetCascadiaNfAsDefault - dependsOn: - - PowerShell - - InstallCascadiaCodeNerdFonts - properties: - getScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return @{ fontFace = $null } } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $json = $clean | ConvertFrom-Json - return @{ fontFace = $json.profiles.defaults.font.face } - testScript: | - $fontFace = 'Cascadia Mono NF' - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return $true } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $json = $clean | ConvertFrom-Json - return ($json.profiles.defaults.font.face -eq $fontFace) - setScript: | - $fontFace = 'Cascadia Mono NF' - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { throw 'Windows Terminal settings.json not found.' } - Write-Host "Using: $settingsPath" - - Copy-Item $settingsPath "$settingsPath.bak" -Force - - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $json = $clean | ConvertFrom-Json -AsHashtable - - if (-not $json.profiles) { $json.profiles = [ordered]@{} } - if (-not $json.profiles.defaults) { $json.profiles.defaults = [ordered]@{} } - if (-not $json.profiles.defaults.font) { $json.profiles.defaults.font = [ordered]@{} } - $json.profiles.defaults.font.face = $fontFace - - $json | ConvertTo-Json -Depth 32 | Set-Content $settingsPath -Encoding utf8 - Write-Host "Set font to '$fontFace' (backup: $settingsPath.bak)" - metadata: - description: Making Cascadia fonts default - -# ============================================================================= -# Set PowerShell 7 as the default Windows Terminal profile -# NOTE: This cannot use a fragment. Fragments support adding profiles and color -# schemes, but not the top-level defaultProfile setting in settings.json. -# Direct settings.json modification is the only available approach here. -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: ps7default - dependsOn: - - PowerShell - properties: - getScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return @{ defaultProfile = $null; ps7ProfileGuid = $null } } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $settings = $clean | ConvertFrom-Json - $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 - return @{ defaultProfile = $settings.defaultProfile; ps7ProfileGuid = $ps7.guid } - testScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return $true } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $settings = $clean | ConvertFrom-Json - $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 - if (-not $ps7) { return $true } - return ($settings.defaultProfile -eq $ps7.guid) - setScript: | - $settingsPath = @( - Get-ChildItem "$env:LOCALAPPDATA\Packages" -Filter 'Microsoft.WindowsTerminal*' -Directory -EA SilentlyContinue | - ForEach-Object { Join-Path $_.FullName 'LocalState\settings.json' } - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | Select-Object -First 1 - if (-not $settingsPath) { return } - $raw = Get-Content $settingsPath -Raw - $clean = [regex]::Replace($raw, '/\*[\s\S]*?\*/', '') - $clean = [regex]::Replace($clean, '(?m)^\s*//.*$', '') - $settings = $clean | ConvertFrom-Json - $ps7 = $settings.profiles.list | Where-Object { $_.source -eq 'Windows.Terminal.PowershellCore' -or $_.name -eq 'PowerShell' } | Select-Object -First 1 - if ($ps7 -and $settings.defaultProfile -ne $ps7.guid) { - $settings.defaultProfile = $ps7.guid - $settings | ConvertTo-Json -Depth 10 | Set-Content $settingsPath -Encoding UTF8 - } - metadata: - description: Set PowerShell 7 as the default Windows Terminal profile - -# ============================================================================= -# Registry — HKLM keys (elevated, native v3 Microsoft.Windows/Registry) -# ============================================================================= - - -# ============================================================================= -# Theme and OS -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: Sudo - properties: - keyPath: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Sudo - valueName: Enabled - valueData: - DWord: 3 - metadata: - description: Enable Sudo in inline mode - securityContext: elevated - -# Developer Mode: AllowDevelopmentWithoutDevLicense=1 (replaces -# Microsoft.Windows.Settings/WindowsSettings DeveloperMode:true) -- type: Microsoft.Windows/Registry - name: DeveloperMode - properties: - keyPath: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock - valueName: AllowDevelopmentWithoutDevLicense - valueData: - DWord: 1 - metadata: - description: Enable Developer Mode (sideload + dev features) - securityContext: elevated - -# Long path support (replaces Microsoft.Windows.Developer/EnableLongPathSupport) -- type: Microsoft.Windows/Registry - name: LongPaths - properties: - keyPath: HKLM\SYSTEM\CurrentControlSet\Control\FileSystem - valueName: LongPathsEnabled - valueData: - DWord: 1 - metadata: - description: Enable Win32 long path support - securityContext: elevated - -# Remote Desktop (replaces Microsoft.Windows.Developer/EnableRemoteDesktop) -- type: Microsoft.Windows/Registry - name: RemoteDesktop - properties: - keyPath: HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server - valueName: fDenyTSConnections - valueData: - DWord: 0 - metadata: - description: Enable Remote Desktop (firewall rule still needs separate enable) - securityContext: elevated - -# ============================================================================= -# File Explorer and Desktop -# ============================================================================= - -#- type: Microsoft.Windows/Registry -# name: HideDesktopIcons -# dependsOn: -# - ElevationCheck -# properties: -# keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced -# valueName: HideIcons -# valueData: -# DWord: 1 -# metadata: -# description: Hide desktop icons - -# Show file extensions (replaces Microsoft.Windows.Developer/WindowsExplorer -# FileExtensions:Show) -- type: Microsoft.Windows/Registry - name: ShowFileExtensions - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: HideFileExt - valueData: - DWord: 0 - metadata: - description: Show file extensions in Explorer - securityContext: elevated - -# Show hidden files (replaces Microsoft.Windows.Developer/WindowsExplorer -# HiddenFiles:Show) -- type: Microsoft.Windows/Registry - name: ShowHiddenFiles - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: Hidden - valueData: - DWord: 1 - metadata: - description: Show hidden files in Explorer - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: FullPathTitlebar - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: FullPathAddress - valueData: - DWord: 1 - metadata: - description: Show full path in Explorer titlebar - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: OpenThisPC - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: LaunchTo - valueData: - DWord: 1 - metadata: - description: Open File Explorer to This PC - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: FrequentFolders - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: ShowFrequent - valueData: - DWord: 0 - metadata: - description: Disable frequent folders in Quick Access - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: FrequentFiles - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer - valueName: ShowRecent - valueData: - DWord: 0 - metadata: - description: Disable frequent files in Quick Access - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: RecommendedFiles - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer - valueName: ShowCloudFilesInQuickAccess - valueData: - DWord: 0 - metadata: - description: Disable recommended/cloud files in Quick Access - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: GitCodeFolders - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: NavPaneShowVersionControl - valueData: - DWord: 1 - metadata: - description: Enable Git integration in File Explorer - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: TipsOff - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: ShowSyncProviderNotifications - valueData: - DWord: 0 - metadata: - description: Disable sync provider notifications (tips) - securityContext: elevated - -# ============================================================================= -# Notifications and Lock Screen -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: DoNotDisturb - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings - valueName: NOC_GLOBAL_SETTING_TOASTS_ENABLED - valueData: - DWord: 0 - metadata: - description: Enable Do Not Disturb (disable all notifications) - securityContext: elevated - -# ============================================================================= -# Taskbar -# ============================================================================= - -# Hide Widgets button (replaces Microsoft.Windows.Developer/Taskbar -# WidgetsButton:Hide). 0 = hidden. -- type: Microsoft.Windows/Registry - name: TaskbarHideWidgets - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: TaskbarDa - valueData: - DWord: 0 - metadata: - description: Hide Widgets button on the taskbar - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: BluetoothOff - properties: - keyPath: HKCU\Control Panel\Bluetooth - valueName: Notification Area Icon - valueData: - DWord: 0 - metadata: - description: Hide Bluetooth icon in taskbar notification area - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: EndTask - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: TaskbarEndTask - valueData: - DWord: 1 - metadata: - description: Enable "End Task" on right-click of taskbar icons - securityContext: elevated - -# ============================================================================= -# Start and Search -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: WebSearchOff - properties: - keyPath: HKCU\SOFTWARE\Policies\Microsoft\Windows\Explorer - valueName: DisableSearchBoxSuggestions - valueData: - DWord: 1 - metadata: - description: Disable web search in Start/Search - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: SearchHightlightOff - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\SearchSettings - valueName: IsDynamicSearchBoxEnabled - valueData: - DWord: 0 - metadata: - description: Disable Show search highlights - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: StartRecommendations - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced - valueName: Start_IrisRecommendations - valueData: - DWord: 0 - metadata: - description: Disable Start menu recommendations - securityContext: elevated - -# ============================================================================= -# Services and Features -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: WidgetServiceOff - properties: - keyPath: HKLM\SOFTWARE\Policies\Microsoft\Dsh - valueName: AllowNewsAndInterests - valueData: - DWord: 0 - metadata: - description: Disable Widget service - securityContext: elevated - - -# ============================================================================= -# Registry — HKLM keys (elevated, native v3 Microsoft.Windows/Registry) -# -# Microsoft Edge policies — https://learn.microsoft.com/deployedge/microsoft-edge-policies#newtabpage -# ============================================================================= - -- type: Microsoft.Windows/Registry - name: EdgeNewTab - properties: - keyPath: HKLM\SOFTWARE\Policies\Microsoft\Edge - valueName: NewTabPageLocation - valueData: - String: about:blank - metadata: - description: Set Edge new tab to blank - securityContext: elevated - -- type: Microsoft.Windows/Registry - name: EdgeOOBE - properties: - keyPath: HKLM\SOFTWARE\Policies\Microsoft\Edge - valueName: HideFirstRunExperience - valueData: - DWord: 1 - metadata: - description: Disable Edge first-run experience - securityContext: elevated - -# ============================================================================= -# Software Installs -# ============================================================================= - -- type: Microsoft.WinGet/Package - name: Git - properties: - id: Git.Git - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Git - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: GitHubCLI - dependsOn: - - Git - properties: - id: GitHub.Cli - source: winget - useLatest: true - installMode: silent - metadata: - description: Install GitHub CLI - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: GitHubCopilot - dependsOn: - - Git - properties: - id: GitHub.Copilot - source: winget - useLatest: true - installMode: silent - metadata: - description: Install GitHub Copilot - -- type: Microsoft.WinGet/Package - name: VSCode - properties: - id: Microsoft.VisualStudioCode - source: winget - useLatest: true - installMode: silent - metadata: - description: Install VS Code - -- type: Microsoft.WinGet/Package - name: DotnetSdk - properties: - id: Microsoft.dotnet.SDK.10 - source: winget - useLatest: true - installMode: silent - metadata: - description: Install dotnet SDK - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: Python - properties: - id: Python.Python.3.14 - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Python 3.14 - -- type: Microsoft.WinGet/Package - name: UV - properties: - id: astral-sh.uv - source: winget - useLatest: true - installMode: silent - metadata: - description: Install UV (Python tool) - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: NodeJS - properties: - id: OpenJS.NodeJS.LTS - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Node.js 24 LTS - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: nvmForNode - properties: - id: CoreyButler.NVMforWindows - source: winget - useLatest: true - installMode: silent - metadata: - description: Install NVM - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: Coreutils - properties: - id: Microsoft.Coreutils - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Coreutils for Windows - securityContext: elevated - -- type: Microsoft.WinGet/Package - name: OhMyPosh - properties: - id: JanDeDobbeleer.OhMyPosh - source: winget - useLatest: true - installMode: silent - metadata: - description: Install Oh My Posh (Optional) - -- type: Microsoft.WinGet/Package - name: winappCli - properties: - id: Microsoft.winappcli - source: winget - useLatest: true - installMode: silent - metadata: - description: Install winAppCLI - -- type: Microsoft.WinGet/Package - name: PowerToys - properties: - id: Microsoft.PowerToys - source: winget - useLatest: true - installMode: silent - metadata: - description: Install PowerToys (Optional) - -- type: Microsoft.Windows/Registry - name: PowerToysAOT - dependsOn: - - PowerToys - properties: - keyPath: HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Notifications\Settings\PowerToys - valueName: Enabled - valueData: - DWord: 0 - metadata: - description: Disable PowerToys AOT notifications - -# ============================================================================= -# Include Oh My Posh init in PowerShell 7 profile -# ============================================================================= -- type: OhMyPosh/Shell - name: ohMyPoshProfileSet - dependsOn: - - OhMyPosh - properties: - states: - - name: pwsh - command: | - $(if (Get-Command 'oh-my-posh' -ErrorAction SilentlyContinue) { - oh-my-posh init pwsh - # Set output encoding to UTF-8 - [Console]::OutputEncoding =[System.Text.Encoding]::UTF8 - # Set input encoding to UTF-8 (for reading user input with non-ASCII chars) - [Console]::InputEncoding =[System.Text.Encoding]::UTF8 - }) - skipExistingInit: true - metadata: - description: Setup OhMyPosh in PowerShell 7 - -# ============================================================================= -# Add GitHub Copilot profile to Windows Terminal -# Uses a fragment file so settings.json is never touched directly. -# Fragment file: %LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: GitHubCopilotProfile - dependsOn: - - PowerShell - - GitHubCopilot - - Terminal - properties: - getScript: | - $fragmentPath = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json' - return @{ fragmentPresent = (Test-Path $fragmentPath) } - testScript: | - $fragmentPath = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig\github-copilot.fragment.json' - return (Test-Path $fragmentPath) - setScript: | - $fragmentsDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\DevConfig' - New-Item -ItemType Directory -Path $fragmentsDir -Force | Out-Null - - # Download icon alongside the fragment file so the relative path "copilot.png" resolves correctly. - $iconPath = Join-Path $fragmentsDir 'copilot.png' - Invoke-WebRequest -Uri 'https://github.githubassets.com/favicons/favicon-dark.png' -OutFile $iconPath -UseBasicParsing - - $fragment = @{ - profiles = @( - @{ - guid = '{b1a4d2c8-6f3e-4a7b-9e2d-1c8f5a3b7d91}' - name = 'GitHub Copilot' - commandline = 'pwsh.exe -NoExit -Command "copilot"' - icon = 'copilot.png' - startingDirectory = '%USERPROFILE%' - hidden = $false - tabTitle = 'Copilot' - } - ) - } - - $fragmentFile = Join-Path $fragmentsDir 'github-copilot.fragment.json' - $fragment | ConvertTo-Json -Depth 8 | Out-File -FilePath $fragmentFile -Encoding Utf8 - - # Touch settings.json to trigger WT's hot-reload (re-scans Fragments\*.json). - @( - "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json", - "$env:LOCALAPPDATA\Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\settings.json", - "$env:LOCALAPPDATA\Microsoft\Windows Terminal\settings.json" - ) | Where-Object { Test-Path $_ } | ForEach-Object { - try { (Get-Item -LiteralPath $_).LastWriteTime = Get-Date } catch {} - } - - Write-Host "GitHub Copilot profile fragment written to $fragmentFile" -ForegroundColor Green - Write-Host "Open Windows Terminal: the 'GitHub Copilot' profile is available in the dropdown." -ForegroundColor Cyan - metadata: - description: Create Github Copilot Profile - -# ============================================================================= -# Install Win Skills -# ============================================================================= -- type: Microsoft.DSC.Transitional/PowerShellScript - name: InstallWinUITemplates - dependsOn: - - PowerShell - - DotnetSdk - properties: - getScript: | - $installed = [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) - return @{ installed = $installed } - testScript: | - return [bool](dotnet new list 2>&1 | Select-String -Pattern 'winui' -CaseSensitive:$false) - setScript: | - dotnet new install Microsoft.WindowsAppSDK.WinUI.CSharp.Templates - metadata: - description: Install WinUI dotnet new templates - -- type: Microsoft.DSC.Transitional/PowerShellScript - name: AddWinSkillsMarketplace - dependsOn: - - PowerShell - - GitHubCopilot - properties: - getScript: | - $present = [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') - return @{ present = $present } - testScript: | - return [bool](copilot plugin marketplace list 2>&1 | Select-String 'win-dev-skills') - setScript: | - copilot plugin marketplace add microsoft/win-dev-skills - metadata: - description: Add win-dev-skills to the Copilot plugin marketplace - -- type: Microsoft.DSC.Transitional/PowerShellScript - name: InstallWinUIPlugin - dependsOn: - - PowerShell - - AddWinSkillsMarketplace - properties: - getScript: | - $installed = [bool](copilot plugin list 2>&1 | Select-String 'winui') - return @{ installed = $installed } - testScript: | - return [bool](copilot plugin list 2>&1 | Select-String 'winui') - setScript: | - copilot plugin install winui@win-dev-skills - metadata: - description: Install the WinUI Copilot plugin from win-dev-skills diff --git a/src/windows-dev-config/install.ps1 b/src/windows-dev-config/install.ps1 index 1194635..f32017e 100644 --- a/src/windows-dev-config/install.ps1 +++ b/src/windows-dev-config/install.ps1 @@ -1,29 +1,545 @@ <# .SYNOPSIS - Apply the Calm OS user-experience configuration on Windows. + Install the Windows Developer Config with one UAC prompt and reboot-safe resume. .DESCRIPTION - Thin CI/dev shim around `dev-config.winget`, a winget DSC configuration - that sets up the full Calm OS developer workstation (apps, distraction- - free desktop, taskbar polish, Recall off, dark theme, WSL + Ubuntu). - - The shim only: - * applies the DSC config with retry, - * rehydrates PATH in the current session, - * emits `INSTALL_OK: calm-os` for the test harness. - - RequireCommands lists `git` because the master config installs it - unconditionally; asserting it on PATH catches a clean-install failure - early. + Slipstream stages this signed payload under ProgramData, elevates once, repairs + WinGet if needed, enables WSL, resumes after reboot through an elevated + interactive-user scheduled task, applies the declared packages and settings, + and independently verifies the result. + + Run -Action Validate before a signing pass to perform non-destructive payload + validation. -AllowUnsigned is only for source-tree development. #> [CmdletBinding()] -param() +param( + [ValidateSet('Start', 'Resume', 'Status', 'Validate', 'Cleanup')] + [string] $Action = 'Start', + + [string] $RunId, + [string] $PayloadRoot, + [string] $OriginalUserSid, + [string] $OriginalUserName, + + [switch] $AllowUnsigned, + [switch] $NoRestart +) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -& (Join-Path $PSScriptRoot '..\Workloads\_common\apply-configuration.ps1') ` - -Id 'calm-os' ` - -ConfigFile (Join-Path $PSScriptRoot 'dev-config.winget') ` - -RequireCommands @('git') +$bootstrapIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() +$bootstrapPrincipal = [Security.Principal.WindowsPrincipal]::new($bootstrapIdentity) +if ($bootstrapPrincipal.IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator +)) { + $windowsPowerShellModules = [IO.Path]::Combine( + [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows), + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'Modules' + ) + $env:PSModulePath = @( + [IO.Path]::Combine($PSHOME, 'Modules'), + $windowsPowerShellModules + ) -join ';' +} + +$sourceRoot = $PSScriptRoot +if ([string]::IsNullOrWhiteSpace($sourceRoot)) { + throw @' +The streamed one-line loader is not published yet. For the Slipstream preview, +download the signed pipeline artifact and run windows-dev-config\install.ps1. +'@ +} + +function Invoke-SlipstreamWindowsPowerShellRelaunch { + $invocationArguments = @( + "-Action '$($Action.Replace("'", "''"))'" + ) + foreach ($entry in @( + @{ Name = 'RunId'; Value = $RunId }, + @{ Name = 'PayloadRoot'; Value = $PayloadRoot }, + @{ Name = 'OriginalUserSid'; Value = $OriginalUserSid }, + @{ Name = 'OriginalUserName'; Value = $OriginalUserName } + )) { + if (-not [string]::IsNullOrWhiteSpace($entry.Value)) { + $escapedValue = $entry.Value.Replace("'", "''") + $invocationArguments += "-$($entry.Name) '$escapedValue'" + } + } + if ($AllowUnsigned) { + $invocationArguments += '-AllowUnsigned' + } + if ($NoRestart) { + $invocationArguments += '-NoRestart' + } + + $escapedScript = $PSCommandPath.Replace("'", "''") + $command = @" +`$result = & '$escapedScript' $($invocationArguments -join ' ') +if (`$result -is [int]) { exit `$result } +exit 0 +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $executionPolicy = if ($AllowUnsigned) { 'Bypass' } else { 'AllSigned' } + $systemDirectory = if ([Environment]::Is64BitOperatingSystem -and + -not [Environment]::Is64BitProcess) { + 'Sysnative' + } + else { + 'System32' + } + $nativePowerShell = [IO.Path]::Combine( + [Environment]::GetFolderPath([Environment+SpecialFolder]::Windows), + $systemDirectory, + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + $process = Start-Process ` + -FilePath $nativePowerShell ` + -ArgumentList @( + '-NoLogo', + '-NoProfile', + '-ExecutionPolicy', $executionPolicy, + '-EncodedCommand', $encoded + ) ` + -Wait ` + -PassThru + return $process.ExitCode +} + +$requiresNativeRuntime = $Action -in @('Start', 'Resume', 'Cleanup') +$requiresWindowsPowerShell = $requiresNativeRuntime -and ( + $PSVersionTable.PSEdition -ne 'Desktop' -or + ([Environment]::Is64BitOperatingSystem -and + -not [Environment]::Is64BitProcess) +) +if ($requiresWindowsPowerShell) { + $nativeExitCode = Invoke-SlipstreamWindowsPowerShellRelaunch + if ($nativeExitCode -eq 3010) { + exit 3010 + } + if ($nativeExitCode -ne 0) { + throw "The native Windows PowerShell Slipstream process failed with exit code $nativeExitCode." + } + return +} + +$script:SlipstreamScriptBodyHashes = @{ + 'bootstrap\common.ps1' = '12D0B0B660B588FF8BFB730D4B104EBD1E313F86D3C9BBC0C79F032C15D3AF84' + 'bootstrap\controller.ps1' = 'EC64CAFFADB2D6CD30F265A5687A028BF9EEF24B066D42CB194D1B830C8A4779' + 'bootstrap\platform.ps1' = '1FAEEDF7C0CDCE7E1BE751E67A8A9B978EEB1AD4420C0272639828361895FDF0' + 'bootstrap\resume.ps1' = 'A541E3A2C9824FE33AA24EFE6207FD4A25E0A45CF5C1AB249BF0AF54ACC47F34' + 'bootstrap\configure.ps1' = '5515B28F39DDFB9DE895D6722B2F7E8056CCFA18908F7E486BF0CA9EE8007315' + 'bootstrap\user.ps1' = '77593E5F4501B3B80F30082FA1B1A62927253489836766642257BF4BF365F4DA' + 'bootstrap\verify.ps1' = 'B9BD1912CAAE53A79A8D686E818071FF3C2C62FD3D4479FBD25F2B55BA7E7066' +} +$script:SlipstreamInitialConfigHashes = @{ + 'config\packages.json' = '75152DFEB6DD08A3718D6CA9486A7D660051E06103B942A029A13F6112DA2BE3' + 'config\registry.json' = '84E5947C1FE4BB0E28411290628FB388444DC15E365C9E2BD04FF41A7E3F15D8' +} + +function Get-SlipstreamInitialTextHash { + param( + [Parameter(Mandatory)] [string] $Path, + [switch] $StripSignatureBlock + ) + + $bytes = [IO.File]::ReadAllBytes($Path) + if ($bytes.Length -ge 3 -and + $bytes[0] -eq 0xEF -and + $bytes[1] -eq 0xBB -and + $bytes[2] -eq 0xBF) { + $bytes = $bytes[3..($bytes.Length - 1)] + } + $text = [Text.Encoding]::UTF8.GetString($bytes) + $lines = $text -split "`r`n|`n" + if ($StripSignatureBlock) { + for ($index = 0; $index -lt $lines.Length; $index++) { + if ($lines[$index].Trim() -eq '# SIG # Begin signature block') { + $lines = if ($index -eq 0) { @() } else { $lines[0..($index - 1)] } + break + } + } + } + + $normalized = [Text.Encoding]::UTF8.GetBytes([string]::Join("`n", $lines)) + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + return [BitConverter]::ToString( + $sha256.ComputeHash($normalized) + ).Replace('-', '') + } + finally { + $sha256.Dispose() + } +} + +function Test-SlipstreamInitialPayload { + param( + [Parameter(Mandatory)] [string] $Root, + [switch] $AllowUnsignedPayload + ) + + $requiredScripts = @( + 'install.ps1', + 'bootstrap\common.ps1', + 'bootstrap\controller.ps1', + 'bootstrap\platform.ps1', + 'bootstrap\resume.ps1', + 'bootstrap\configure.ps1', + 'bootstrap\user.ps1', + 'bootstrap\verify.ps1' + ) + foreach ($relativePath in $requiredScripts) { + $path = Join-Path $Root $relativePath + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Slipstream payload is incomplete; missing $relativePath." + } + + $parseErrors = $null + [void][System.Management.Automation.Language.Parser]::ParseFile( + $path, + [ref]$null, + [ref]$parseErrors + ) + if ($parseErrors) { + throw "PowerShell parse failure in $relativePath`: $($parseErrors[0].Message)" + } + + if (-not $AllowUnsignedPayload) { + $signature = Get-AuthenticodeSignature -LiteralPath $path + if ($signature.Status -ne 'Valid') { + throw "Invalid Authenticode signature on $relativePath`: $($signature.Status)" + } + if ($signature.SignerCertificate.Subject -notmatch 'O=Microsoft Corporation') { + throw "Unexpected signer on $relativePath`: $($signature.SignerCertificate.Subject)" + } + } + + if ($relativePath -ne 'install.ps1') { + $actualHash = Get-SlipstreamInitialTextHash ` + -Path $path ` + -StripSignatureBlock + $expectedHash = $script:SlipstreamScriptBodyHashes[$relativePath] + if ($actualHash -ne $expectedHash) { + throw "Body hash mismatch for $relativePath. Expected $expectedHash, got $actualHash." + } + } + } + + foreach ($relativePath in $script:SlipstreamInitialConfigHashes.Keys) { + $path = Join-Path $Root $relativePath + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Slipstream payload is incomplete; missing $relativePath." + } + $actualHash = Get-SlipstreamInitialTextHash -Path $path + $expectedHash = $script:SlipstreamInitialConfigHashes[$relativePath] + if ($actualHash -ne $expectedHash) { + throw "Hash mismatch for $relativePath. Expected $expectedHash, got $actualHash." + } + } +} + +Test-SlipstreamInitialPayload -Root $sourceRoot -AllowUnsignedPayload:$AllowUnsigned +. (Join-Path $sourceRoot 'bootstrap\common.ps1') +. (Join-Path $sourceRoot 'bootstrap\resume.ps1') + +function Set-SlipstreamDirectoryAcl { + param([Parameter(Mandatory)] [string] $Path) + + $acl = [System.Security.AccessControl.DirectorySecurity]::new() + $acl.SetAccessRuleProtection($true, $false) + $inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit' + $propagation = [System.Security.AccessControl.PropagationFlags]::None + foreach ($rule in @( + [System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.SecurityIdentifier]::new('S-1-5-18'), + 'FullControl', + $inheritance, + $propagation, + 'Allow' + ), + [System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'), + 'FullControl', + $inheritance, + $propagation, + 'Allow' + ), + [System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.SecurityIdentifier]::new('S-1-5-32-545'), + 'ReadAndExecute', + $inheritance, + $propagation, + 'Allow' + ) + )) { + $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $Path -AclObject $acl +} + +function Copy-SlipstreamPayload { + param( + [Parameter(Mandatory)] [string] $Source, + [Parameter(Mandatory)] [string] $Destination + ) + + if (-not (Test-Path -LiteralPath $script:SlipstreamProgramDataRoot)) { + New-Item ` + -ItemType Directory ` + -Path $script:SlipstreamProgramDataRoot ` + -Force | Out-Null + } + Set-SlipstreamDirectoryAcl -Path $script:SlipstreamProgramDataRoot + + if (Test-Path -LiteralPath $Destination) { + throw "Refusing to overwrite an existing pinned payload: $Destination" + } + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + Set-SlipstreamDirectoryAcl -Path $Destination + + foreach ($item in Get-ChildItem -LiteralPath $Source -Force) { + Copy-Item ` + -LiteralPath $item.FullName ` + -Destination $Destination ` + -Recurse ` + -Force + } +} + +function Invoke-SlipstreamElevation { + param( + [Parameter(Mandatory)] [string] $Sid, + [Parameter(Mandatory)] [string] $UserName + ) + + if (-not $PSCommandPath) { + throw 'Cannot self-elevate a streamed script. Run the signed install.ps1 file.' + } + + $escapedScript = $PSCommandPath.Replace("'", "''") + $escapedSid = $Sid.Replace("'", "''") + $escapedName = $UserName.Replace("'", "''") + $command = @" +`$result = & '$escapedScript' -Action Start -OriginalUserSid '$escapedSid' -OriginalUserName '$escapedName'$(if ($AllowUnsigned) { ' -AllowUnsigned' })$(if ($NoRestart) { ' -NoRestart' }) +if (`$result -is [int]) { exit `$result } +exit 0 +"@ + $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($command)) + $executionPolicy = if ($AllowUnsigned) { 'Bypass' } else { 'AllSigned' } + $systemDirectory = if ([Environment]::Is64BitOperatingSystem -and + -not [Environment]::Is64BitProcess) { + 'Sysnative' + } + else { + 'System32' + } + $process = Start-Process ` + -FilePath (Join-Path $env:SystemRoot "$systemDirectory\WindowsPowerShell\v1.0\powershell.exe") ` + -ArgumentList @('-NoLogo', '-NoProfile', '-ExecutionPolicy', $executionPolicy, '-EncodedCommand', $encoded) ` + -Verb RunAs ` + -Wait ` + -PassThru + return $process.ExitCode +} + +function Get-SlipstreamRecoverableRun { + param([Parameter(Mandatory)] [string] $UserSid) + + $runsRoot = Join-Path $script:SlipstreamProgramDataRoot 'runs' + if (-not (Test-Path -LiteralPath $runsRoot)) { + return $null + } + + $states = foreach ($stateFile in Get-ChildItem -LiteralPath $runsRoot -Filter state.json -Recurse -File) { + try { + $state = Get-Content -LiteralPath $stateFile.FullName -Raw -Encoding UTF8 | + ConvertFrom-Json + if ($state.originalUserSid -eq $UserSid -and + $state.status -ne 'Complete' -and + $state.payloadRoot -and + (Test-Path -LiteralPath $state.payloadRoot -PathType Container)) { + $state + } + } + catch { + Write-Warning "Ignoring unreadable Slipstream state: $($stateFile.FullName)" + } + } + + return $states | + Sort-Object updatedAtUtc -Descending | + Select-Object -First 1 +} + +function Show-SlipstreamStatus { + $runsRoot = Join-Path $script:SlipstreamProgramDataRoot 'runs' + if (-not (Test-Path -LiteralPath $runsRoot)) { + Write-Host 'No Windows Developer Config runs were found.' + return + } + + $states = foreach ($stateFile in Get-ChildItem -LiteralPath $runsRoot -Filter state.json -Recurse -File) { + try { + Get-Content -LiteralPath $stateFile.FullName -Raw -Encoding UTF8 | + ConvertFrom-Json + } + catch { + Write-Warning "Could not read $($stateFile.FullName): $($_.Exception.Message)" + } + } + $states | + Sort-Object updatedAtUtc -Descending | + Select-Object runId, status, phase, rebootCount, updatedAtUtc, lastError | + Format-List +} + +if ($Action -eq 'Status') { + Show-SlipstreamStatus + return +} + +if ($Action -eq 'Validate') { + $summary = Test-SlipstreamPayload ` + -PayloadRoot $sourceRoot ` + -AllowUnsigned:$AllowUnsigned + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $validationState = [pscustomobject]@{ + runId = [guid]::Empty.ToString() + payloadRoot = $sourceRoot + originalUserSid = $identity.User.Value + } + $task = New-SlipstreamResumeTaskDefinition ` + -State $validationState ` + -AllowUnsigned:$AllowUnsigned ` + -NoRestart:$NoRestart + $userTask = New-SlipstreamUserTaskDefinition ` + -State $validationState ` + -AllowUnsigned:$AllowUnsigned + return [pscustomobject]@{ + Status = 'Valid' + Scripts = $summary.Scripts + Packages = $summary.Packages + RegistryValues = $summary.RegistryValues + TaskLogonType = $task.Principal.LogonType + TaskRunLevel = $task.Principal.RunLevel + UserTaskLogonType = $userTask.Principal.LogonType + UserTaskRunLevel = $userTask.Principal.RunLevel + SignaturesRequired = $summary.SignaturesRequired + } +} + +if ($Action -eq 'Cleanup') { + if ([string]::IsNullOrWhiteSpace($RunId)) { + throw '-RunId is required for cleanup.' + } + if (-not (Test-SlipstreamAdministrator)) { + throw 'Cleanup must run from an elevated PowerShell.' + } + + Unregister-SlipstreamResumeTask -RunId $RunId + $statePath = Get-SlipstreamStatePath -RunId $RunId + if (Test-Path -LiteralPath $statePath) { + $state = Read-SlipstreamState -RunId $RunId + if (Test-Path -LiteralPath $state.payloadRoot) { + Remove-Item -LiteralPath $state.payloadRoot -Recurse -Force + } + } + Write-Host "Cleanup completed for run $RunId." + return +} + +if ($Action -eq 'Start' -and -not (Test-SlipstreamAdministrator)) { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + if (-not (Test-SlipstreamAdministratorMembership)) { + throw @' +Slipstream requires the signed-in user to be a local administrator. Elevating +with a different account would prevent reboot resume from retaining both the +original user's profile and the administrator token without storing credentials. +'@ + } + Test-SlipstreamPayload ` + -PayloadRoot $sourceRoot ` + -AllowUnsigned:$AllowUnsigned | Out-Null + $elevationExitCode = Invoke-SlipstreamElevation ` + -Sid $identity.User.Value ` + -UserName $identity.Name + if ($elevationExitCode -eq 3010) { + exit 3010 + } + if ($elevationExitCode -ne 0) { + throw "Elevated Windows Developer Config failed with exit code $elevationExitCode." + } + return +} + +if (-not (Test-SlipstreamAdministrator)) { + throw "Action '$Action' must run elevated." +} + +if ($Action -eq 'Start') { + if ([string]::IsNullOrWhiteSpace($RunId)) { + $RunId = [guid]::NewGuid().ToString() + } + if ([string]::IsNullOrWhiteSpace($OriginalUserSid) -or + [string]::IsNullOrWhiteSpace($OriginalUserName)) { + $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() + $OriginalUserSid = $identity.User.Value + $OriginalUserName = $identity.Name + } + + $recoverable = Get-SlipstreamRecoverableRun -UserSid $OriginalUserSid + if ($recoverable) { + $RunId = $recoverable.runId + $PayloadRoot = $recoverable.payloadRoot + Write-Host "Resuming existing Slipstream run $RunId." -ForegroundColor Cyan + } + else { + $PayloadRoot = Join-Path ` + (Join-Path $script:SlipstreamProgramDataRoot 'payloads') ` + $RunId + Copy-SlipstreamPayload -Source $sourceRoot -Destination $PayloadRoot + } +} +elseif ($Action -eq 'Resume') { + if ([string]::IsNullOrWhiteSpace($RunId) -or + [string]::IsNullOrWhiteSpace($PayloadRoot)) { + throw '-RunId and -PayloadRoot are required for resume.' + } + $state = Read-SlipstreamState -RunId $RunId + $OriginalUserSid = $state.originalUserSid + $OriginalUserName = $state.originalUserName +} + +Test-SlipstreamInitialPayload ` + -Root $PayloadRoot ` + -AllowUnsignedPayload:$AllowUnsigned +. (Join-Path $PayloadRoot 'bootstrap\common.ps1') +Test-SlipstreamPayload ` + -PayloadRoot $PayloadRoot ` + -AllowUnsigned:$AllowUnsigned | Out-Null + +$controllerPath = Join-Path $PayloadRoot 'bootstrap\controller.ps1' +$result = & $controllerPath ` + -RunId $RunId ` + -PayloadRoot $PayloadRoot ` + -OriginalUserSid $OriginalUserSid ` + -OriginalUserName $OriginalUserName ` + -AllowUnsigned:$AllowUnsigned ` + -NoRestart:$NoRestart + +if ($result -is [int] -and $result -eq 3010) { + if ($Action -eq 'Resume') { + exit 0 + } + exit 3010 +} +if ($result -is [int] -and $result -ne 0) { + exit $result +}