--- - name: Enable required Windows features for WSL tags: [packages, services, wsl] ansible.windows.win_optional_feature: name: - Microsoft-Windows-Subsystem-Linux - VirtualMachinePlatform state: present include_parent: true - name: Gather Windows host version details tags: [packages] ansible.windows.win_powershell: script: | $os = Get-ComputerInfo $buildNumber = [int]$os.OsBuildNumber $Ansible.Result = @{ product_name = $os.WindowsProductName build_number = $buildNumber is_windows_11 = $buildNumber -ge 22000 } $Ansible.Changed = $false register: windows_host_version_state - name: Fail when Windows host is not Windows 11 tags: [packages] ansible.builtin.fail: msg: >- workstation_host_windows is supported only on Windows 11. Detected {{ windows_host_version_state.result.product_name }} build {{ windows_host_version_state.result.build_number }}. when: not (windows_host_version_state.result.is_windows_11 | default(false)) - name: Enable dark mode for Windows apps tags: [packages] ansible.windows.win_regedit: path: HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize name: AppsUseLightTheme data: 0 type: dword register: windows_apps_dark_mode_state when: windows_enable_dark_theme | default(false) - name: Enable dark mode for Windows system surfaces tags: [packages] ansible.windows.win_regedit: path: HKCU:\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize name: SystemUsesLightTheme data: 0 type: dword register: windows_system_dark_mode_state when: windows_enable_dark_theme | default(false) - name: Hide Windows taskbar search box tags: [packages] ansible.windows.win_regedit: path: HKCU:\Software\Microsoft\Windows\CurrentVersion\Search name: SearchboxTaskbarMode data: 0 type: dword register: windows_taskbar_search_state when: windows_hide_taskbar_search | default(false) - name: Hide Windows 11 taskbar widgets tags: [packages] ansible.windows.win_regedit: path: HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced name: TaskbarDa data: 0 type: dword register: windows_11_widgets_state when: windows_hide_taskbar_widgets | default(false) - name: Disable Windows 11 widgets via policy tags: [packages] ansible.windows.win_regedit: path: HKLM:\SOFTWARE\Policies\Microsoft\Dsh name: AllowNewsAndInterests data: 0 type: dword register: windows_11_widgets_policy_state when: windows_hide_taskbar_widgets | default(false) - name: Note when Windows shell settings may require sign out tags: [packages] ansible.builtin.debug: msg: >- Windows shell appearance settings changed. Sign out and back in, or restart explorer.exe, if taskbar or theme updates do not appear immediately. Windows 11 widget policy changes can require signing out before the widget button disappears from the taskbar. changed_when: false when: >- (windows_apps_dark_mode_state is changed) or (windows_system_dark_mode_state is changed) or (windows_taskbar_search_state is changed) or (windows_11_widgets_state is changed) or (windows_11_widgets_policy_state is changed) - name: Ensure winget is executable on Windows host through PSRP tags: [packages] when: windows_package_backend == 'winget_psrp' ansible.windows.win_powershell: script: | $winget = Get-Command winget.exe -ErrorAction SilentlyContinue if ($null -eq $winget) { throw 'winget.exe is not available on the Windows host. Install App Installer or rerun the bootstrap script.' } & $winget.Source --info *> $null if ($LASTEXITCODE -ne 0) { throw 'winget.exe is not executable through the current PSRP session. Set windows_package_backend to winget_wsl_local on WSL-managed hosts.' } $Ansible.Changed = $false - name: Ensure winget is executable through local Windows PowerShell from WSL tags: [packages] when: windows_package_backend == 'winget_wsl_local' block: - name: Ensure winget_wsl_local backend is running inside WSL ansible.builtin.assert: that: - lookup('ansible.builtin.env', 'WSL_DISTRO_NAME') | length > 0 fail_msg: >- winget_wsl_local requires running the playbook from inside the target machine's WSL environment. - name: Get local Windows host name through WSL interop ansible.builtin.shell: | powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command '$env:COMPUTERNAME' args: executable: /bin/bash delegate_to: localhost register: local_windows_host_name_state changed_when: false - name: Get local Windows host IP addresses through WSL interop ansible.builtin.shell: | powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ' Get-NetIPAddress -AddressFamily IPv4, IPv6 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notin @("127.0.0.1", "::1") -and $_.IPAddress -notlike "169.254.*" -and $_.IPAddress -notlike "fe80:*" } | Select-Object -ExpandProperty IPAddress ' args: executable: /bin/bash delegate_to: localhost register: local_windows_host_ip_state changed_when: false - name: Ensure winget_wsl_local backend targets the local Windows host ansible.builtin.assert: that: - >- (ansible_host | lower) in ['localhost', '127.0.0.1', '::1'] or (local_windows_host_name_state.stdout | trim | upper) == ((ansible_host | regex_replace('\\..*$', '')) | upper) or (ansible_host | lower) in (local_windows_host_ip_state.stdout_lines | map('trim') | map('lower') | list) fail_msg: >- winget_wsl_local can only target the local Windows host reached through WSL interop. Local Windows host '{{ local_windows_host_name_state.stdout | trim }}' with addresses {{ local_windows_host_ip_state.stdout_lines | map('trim') | list }} does not match ansible_host '{{ ansible_host }}'. - name: Ensure winget is executable through local Windows PowerShell ansible.builtin.shell: | powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ' $ErrorActionPreference = "Stop" $winget = Get-Command winget.exe -ErrorAction SilentlyContinue if ($null -eq $winget) { throw "winget.exe is not available on the local Windows host. Install App Installer or rerun the bootstrap script." } & $winget.Source --info *> $null if ($LASTEXITCODE -ne 0) { throw "winget.exe is not executable through local Windows PowerShell interop." } ' args: executable: /bin/bash delegate_to: localhost changed_when: false - name: Ensure WSL 2 is the default backend tags: [packages, services, wsl] ansible.windows.win_powershell: script: | $status = & wsl --status 2>$null if ($LASTEXITCODE -eq 0 -and $status -match 'Default Version:\s*2') { $Ansible.Changed = $false return } & wsl --set-default-version 2 if ($LASTEXITCODE -ne 0) { throw 'Failed to set WSL default version to 2.' } $Ansible.Changed = $true - name: Install Windows workstation applications with winget through PSRP tags: [packages] when: windows_package_backend == 'winget_psrp' ansible.windows.win_powershell: script: | $packageId = '{{ item.id }}' $packageName = '{{ item.name | default(item.id) }}' $packageSource = '{{ item.source | default('') }}' $listArgs = @( 'list' '--id', $packageId '--exact' '--accept-source-agreements' '--disable-interactivity' ) $installArgs = @( 'install' '--id', $packageId '--exact' '--silent' '--accept-package-agreements' '--accept-source-agreements' '--disable-interactivity' ) if (-not [string]::IsNullOrWhiteSpace($packageSource)) { $listArgs += @('--source', $packageSource) $installArgs += @('--source', $packageSource) } $installed = & winget @listArgs 2>$null if ($LASTEXITCODE -eq 0 -and $installed -match [regex]::Escape($packageId)) { $Ansible.Changed = $false return } & winget @installArgs if ($LASTEXITCODE -ne 0) { throw "Failed to install $packageName with winget" } $Ansible.Changed = $true loop: "{{ windows_winget_packages | default([]) }}" loop_control: label: "{{ item.id }}" - name: Install Windows workstation applications with winget through WSL local backend tags: [packages] when: windows_package_backend == 'winget_wsl_local' ansible.builtin.shell: | powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ' $ErrorActionPreference = "Stop" $packageId = "{{ item.id }}" $packageName = "{{ item.name | default(item.id) }}" $packageSource = "{{ item.source | default('') }}" $winget = Get-Command winget.exe -ErrorAction Stop $listArgs = @( "list" "--id", $packageId "--exact" "--accept-source-agreements" "--disable-interactivity" ) $installArgs = @( "install" "--id", $packageId "--exact" "--silent" "--accept-package-agreements" "--accept-source-agreements" "--disable-interactivity" ) if (-not [string]::IsNullOrWhiteSpace($packageSource)) { $listArgs += @("--source", $packageSource) $installArgs += @("--source", $packageSource) } $installed = & $winget.Source @listArgs 2>$null if ($LASTEXITCODE -eq 0 -and $installed -match [regex]::Escape($packageId)) { [Console]::Out.WriteLine("changed=false") exit 0 } & $winget.Source @installArgs if ($LASTEXITCODE -ne 0) { throw "Failed to install $packageName with winget" } [Console]::Out.WriteLine("changed=true") ' args: executable: /bin/bash delegate_to: localhost register: windows_wsl_local_winget_install_state changed_when: "'changed=true' in windows_wsl_local_winget_install_state.stdout" loop: "{{ windows_winget_packages | default([]) }}" loop_control: label: "{{ item.id }}" - name: Configure Windows taskbar pin layout tags: [packages] when: (windows_taskbar_pins | default([])) | length > 0 block: - name: Ensure Windows taskbar layout directory exists ansible.windows.win_file: path: "{{ windows_taskbar_layout_directory }}" state: directory - name: Render Windows taskbar layout policy file ansible.windows.win_template: src: taskbar-layout.xml.j2 dest: "{{ windows_taskbar_layout_path }}" register: windows_taskbar_layout_file_state - name: Enable Windows taskbar layout policy ansible.windows.win_regedit: path: HKCU:\Software\Policies\Microsoft\Windows\Explorer name: LockedStartLayout data: 1 type: dword register: windows_taskbar_layout_policy_state - name: Set Windows taskbar layout policy file path ansible.windows.win_regedit: path: HKCU:\Software\Policies\Microsoft\Windows\Explorer name: StartLayoutFile data: "{{ windows_taskbar_layout_path }}" type: expandstring register: windows_taskbar_layout_policy_path_state - name: Set Windows taskbar layout policy refresh behavior ansible.windows.win_regedit: path: HKCU:\Software\Policies\Microsoft\Windows\Explorer name: ReapplyStartLayoutEveryLogon data: "{{ (windows_taskbar_reapply_every_logon | default(false)) | ternary(1, 0) }}" type: dword register: windows_taskbar_layout_policy_reapply_state - name: Note when Windows taskbar pin layout may require sign out ansible.builtin.debug: msg: >- Windows taskbar pin policy changed. Sign out and back in if the updated pin order does not appear immediately. changed_when: false when: >- (windows_taskbar_layout_file_state is changed) or (windows_taskbar_layout_policy_state is changed) or (windows_taskbar_layout_policy_path_state is changed) or (windows_taskbar_layout_policy_reapply_state is changed) - name: Install VS Code WSL extensions on Windows host tags: [packages, vscode] ansible.windows.win_powershell: script: | $extensionId = '{{ item }}' $code = Get-Command code.cmd -ErrorAction SilentlyContinue if ($null -eq $code) { throw 'code.cmd is not available. Ensure Visual Studio Code is installed before managing extensions.' } $installedExtensions = & $code.Source --list-extensions if ($installedExtensions -contains $extensionId) { $Ansible.Changed = $false return } & $code.Source --install-extension $extensionId --force if ($LASTEXITCODE -ne 0) { throw "Failed to install VS Code extension $extensionId" } $Ansible.Changed = $true loop: "{{ windows_vscode_extensions | default([]) }}" loop_control: label: "{{ item }}" - name: Set Windows Terminal default profile to Ubuntu tags: [packages, wsl] ansible.windows.win_powershell: script: | $terminalSettingsPath = Join-Path $env:LOCALAPPDATA 'Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\settings.json' if (-not (Test-Path $terminalSettingsPath)) { throw 'Windows Terminal settings.json was not found. Launch Windows Terminal once before applying the default profile configuration.' } $settings = Get-Content -Path $terminalSettingsPath -Raw | ConvertFrom-Json if ($null -eq $settings.profiles -or $null -eq $settings.profiles.list) { throw 'Windows Terminal settings.json does not contain a profiles.list section.' } $targetProfile = $settings.profiles.list | Where-Object { $_.name -eq '{{ windows_terminal_default_profile_name }}' -and -not ($_.PSObject.Properties.Name -contains 'hidden' -and $_.hidden) } | Select-Object -First 1 if ($null -eq $targetProfile) { throw "Windows Terminal visible profile '{{ windows_terminal_default_profile_name }}' was not found. Ensure the Ubuntu WSL profile exists and is not hidden in Windows Terminal first." } if ($settings.defaultProfile -eq $targetProfile.guid) { $Ansible.Changed = $false return } $settings.defaultProfile = $targetProfile.guid $settings | ConvertTo-Json -Depth 100 | Set-Content -Path $terminalSettingsPath -Encoding utf8 $Ansible.Changed = $true