Tag Archives: Windows 365 Cloud Apps

Windows 365 Cloud Apps — Publishing Apps (Part 2)

29 Sep

In Part 1 we built the provisioning policy and wired it to a group with size/capacity. In Part 2, we’ll actually publish the apps, tweak their details, undo changes when needed, and explain how licensing & concurrency work (with a simple diagram).

Where we work: All Cloud Apps

Once your first Frontline Cloud PC (Shared mode) finishes provisioning, the image’s Start-menu apps appear in Windows 365 → All Cloud Apps as Ready to publish.

You can: Publish, Edit, Reset, and Unpublish apps here. Deletion is tied to the policy assignment (more on that below).

Publish an app (All Cloud Apps)

  1. Intune Admin CenterDevicesWindows 365All Cloud Apps
  2. Pick one or more apps (Word, Excel, PowerPoint and Edge) with status Ready to publishPublish
  3. Watch the status flow:
    • Ready to publishPublishingPublished
  4. Once Published, the app appears in Windows App for all users assigned to the provisioning policy.
  • All Cloud Apps list (Ready → Publishing → Published)

If an app shows Failed: Unpublish it, then publish again. Check that the Start-menu shortcut on your image is valid (path/command still exists).

Edit an app (safe, instant updates)

For a published or ready app, select Edit to adjust:

  • Display name
  • Description
  • Command line (e.g., parameters)
  • Icon path index

Changes inherit scope tags & assignment from the provisioning policy, and updates are immediate in Windows App.

  • Edit dialog (name/description/command/icon index)

Reset an app (rollback to discovered state)

If you went too far with edits, use Reset to revert back to whatever was discovered from the image originally (name/icon/command). Great for quick experiments.

  • Reset confirmation

Unpublish (and how “delete” works)

  • Unpublish: App status goes Published → Ready to publish and the app disappears from Windows App. Its edited details are reset.
  • Delete: There isn’t a “delete app” button—Cloud Apps are discovered from the image. To truly remove an app from scope, remove the provisioning policy’s assignment (or update the image so the Start-menu shortcut no longer exists).
  • Unpublish action

Accessing apps (Windows App)

Users launch Windows App (Windows/macOS/iOS/Android) and see the Published apps. Selecting an app starts a session on a Frontline Cloud PC (Shared mode).

  • A published app can spawn other apps on that Cloud PC when needed (e.g., Outlook opening Edge from a link), even if the other app isn’t separately published.
  • To tightly control what can launch, use Application Control for Windows policies.
  • Windows App with your published apps visible
  • Launch flow (e.g., Outlook → Edge link)

Licensing & monitoring (Frontline Shared mode — explained)

Frontline (Shared mode) is built for brief, task-oriented access with no data persistence per user session. Think “one at a time” use of a shared Cloud PC.

The rules!

  • 1 Frontline license = 1 concurrent session.
  • You can assign many users to the policy, but only N can be active at once (where N = number of Frontline licenses you assigned to that policy).
  • When a user signs out, their data is deleted and the Cloud PC is free for the next user.
  • There’s no concurrency buffer for Frontline Shared mode (and none for GPU-enabled Cloud PCs).

Monitoring concurrency (what to look at)

  • Frontline connection hourly report: See active usage over time; verify you’re not hitting limits.
  • Frontline concurrency alert: Get notified if you breach your concurrency threshold.
  • Note: Concurrency buffer doesn’t apply to GPU or Frontline Shared Cloud PCs—plan capacity accordingly.

Practical sizing tip: Start with a license count that matches your peak simultaneous users for that group/policy. Watch the hourly report for a week, then adjust up/down.

Troubleshooting checklist

  • Published but not visible? Confirm the user is in the assigned group and is using the latest Windows App.
  • Failed on publish? Unpublish → Publish. Validate the Start-menu shortcut on the image and any custom command-line parameters.
  • Unexpected app launches (e.g., Edge opens)? That’s normal when an app calls another binary. Use Application Control if you must restrict it.
  • Hitting concurrency: Users 1..N can connect; N+1 waits. Increase Frontline licenses on the policy or split users into multiple policies sized per peak.

I hope you find this helpful information for creating a Cloud App. If I have missed any steps or details, I will be happy to update the post.

Thanks,
Aresh Sarkari

Windows 365 Cloud Apps – Provisioning Policy – PowerShell (Graph Rest API) – Part 1

26 Sep

This is part one of a two-part series on Windows 365 Cloud Apps. In this post, we’ll walk through what Cloud Apps are and how to create the provisioning policy with PowerShell. In part two, we’ll publish the apps themselves. I’ll also include the PowerShell script that uses Azure/Graph REST APIs.

What is Windows 365 Cloud Apps?

Windows 365 Cloud Apps let you give users access to specific apps streamed from a Cloud PC—without handing out a full desktop to everyone. Under the hood, Cloud Apps run on Windows 365 Frontline Cloud PCs in Shared mode. That licensing model is designed for shift or part-time staff: many users can be assigned, but only one active session per license at a time.

Think of it as “just-the-apps” VDI: Outlook, Word, your line-of-business app—delivered from the cloud—with the management simplicity of Windows 365 and Intune.

Why customers care: You streamline app delivery, lower overhead, and modernize VDI without building and babysitting a big remote desktop estate.

Cloud Apps vs AVD Published Apps vs “Traditional” VDI Published Apps

TopicWindows 365 Cloud AppsAzure Virtual Desktop Published AppsTraditional VDI Published Apps
What users seeIndividual apps streamed from a Cloud PC; no full desktopIndividual apps from session hosts in Azure Virtual DesktopIndividual apps from on-prem or hosted RDS/Horizon/Citrix farms
Infra you manageCloud PC lifecycle via Intune; Microsoft operates the fabricYou design & operate host pools, scaling, FSLogix, imagesYou run the farm: brokers, gateways, hypervisors, storage
Licensing / sessionsFrontline: many users per license, 1 active session per licensePer-user/per-device or CALs + Azure consumption; multiple sessions per hostPer-user/device + on-prem infra costs
Admin planeIntune + Windows 365Azure Portal + ARM + Host pool automationVendor consoles + on-prem change management
App packagingStart-menu discovered apps from the image (MSIX/Appx discovery expanding)MSI/MSIX; MSIX App Attach; image-basedMSI/MST/App-V/Citrix packages, etc.
Who it’s great forTask/shift workers; predictable, lightweight app accessBroad use cases; granular scale & controlHeavily customized legacy estates, on-prem constraints

Mental model:

If you need elastic host pools or platform primitives, choose AVD.

  • If you’re tied to on-prem or specific vendor features, you might keep traditional VDI, but expect more ops work.
  • If you like the “managed Cloud PC” experience and want app-only access, choose Cloud Apps.
  • If you like the “managed Cloud PC” experience and want app-only access, choose Cloud Apps.

PowerShell: create the policy via REST/Graph

  • Auth: Provide $TenantId, $ClientId, $ClientSecret from your app registration. Grant/admin-consent the scopes listed above. If you are not aware of how to create the app registration, you can follow here – How to register an app in Microsoft Entra ID – Microsoft identity platform | Microsoft Learn
  • Image: Set $ImageType (e.g., "gallery") and $ImageId for your chosen image.
  • Region: $RegionName (e.g., australiaeast or "automatic").
  • Assignment:
    • $GroupId: Entra group whose members should see the Cloud Apps.
    • $ServicePlanId: the Frontline size (e.g., FL 2vCPU/8GB/128GB in the example).
    • $AllotmentCount: how many concurrent sessions you want available for this policy.
    • $AllotmentDisplayName: a friendly label that shows up with the assignment.
  • Verification/Polling: The script dumps the policy with assignments and can optionally poll for provisioned Cloud PCs tied to the policy.
  • Get-or-Create a Cloud Apps provisioning policy (userExperienceType = cloudApp, provisioningType = sharedByEntraGroup, Azure AD Join in a specified region).
  • Assigns the policy to an Entra group with service plan, capacity (allotment), and a friendly label

Required permissions (app registration – admin consent):

  • CloudPC.ReadWrite.All (and CloudPC.Read.All)
  • DeviceManagementServiceConfig.ReadWrite.All (for policy config)
<#
Create (or reuse) a Windows 365 "Cloud Apps" provisioning policy, assign an Entra group
with size + capacity + label, then verify assignment (via $expand=assignments) and optionally
poll for provisioned Cloud PCs. Uses Microsoft Graph beta.

Key note: /assignments endpoint returns 404 by design; use $expand=assignments. See MS docs.
#>

# ==========================
# 0) CONFIG — EDIT THESE
# ==========================
$TenantId     = "<Copy/Paste Tenant ID>"
$ClientId     = "<Copy/Paste Client ID>"
$ClientSecret = "<Copy/Paste ClientSecret ID>"

# Policy
$DisplayName  = "Cloud-Apps-Prov-4"
$Description  = "Cloud Apps Prov Policy - Frontline"
$EnableSSO    = $true
$RegionName   = "australiaeast"   # or "automatic"
$Locale       = "en-AU"
$Language     = "en-AU"

# Image (gallery)
$ImageType    = "gallery"
$ImageId      = "microsoftwindowsdesktop_windows-ent-cpc_win11-24H2-ent-cpc-m365"

# Assignment
$GroupId              = "b582705d-48be-4e4b-baac-90e5b50ebdf2"   # Entra ID Group
$ServicePlanId        = "057efbfe-a95d-4263-acb0-12b4a31fed8d"   # FL 2vCPU/8GB/128GB
$AllotmentCount       = 1
$AllotmentDisplayName = "CP-FL-Shared-CloudApp-1"

# Optional provisioning poll
$VerifyDesiredCount   = $AllotmentCount
$VerifyMaxTries       = 30
$VerifyDelaySec       = 30

# ==========================
# Helpers
# ==========================
function New-GraphUri {
  param(
    [Parameter(Mandatory)][string]$Path,        # e.g. "/beta/deviceManagement/virtualEndpoint/provisioningPolicies"
    [hashtable]$Query
  )
  $cleanPath = '/' + ($Path -replace '^\s*/+','' -replace '\s+$','')
  $b = [System.UriBuilder]::new("https","graph.microsoft.com")
  $b.Path = $cleanPath
  if ($Query -and $Query.Count -gt 0) {
    Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue | Out-Null
    $nvc = [System.Web.HttpUtility]::ParseQueryString([string]::Empty)
    foreach ($k in $Query.Keys) { $nvc.Add($k, [string]$Query[$k]) }
    $b.Query = $nvc.ToString()
  } else { $b.Query = "" }
  return $b.Uri.AbsoluteUri
}

function Invoke-Graph {
  param(
    [Parameter(Mandatory)][ValidateSet('GET','POST','PATCH','DELETE','PUT')][string]$Method,
    [Parameter(Mandatory)][string]$Uri,
    [hashtable]$Headers,
    $Body
  )
  try {
    if ($PSBoundParameters.ContainsKey('Body')) {
      return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $Headers -Body ($Body | ConvertTo-Json -Depth 20)
    } else {
      return Invoke-RestMethod -Method $Method -Uri $Uri -Headers $Headers
    }
  } catch {
    Write-Warning "HTTP $Method $Uri failed: $($_.Exception.Message)"
    try {
      $resp = $_.Exception.Response
      if ($resp -and $resp.GetResponseStream()) {
        $reader = New-Object IO.StreamReader($resp.GetResponseStream())
        $text   = $reader.ReadToEnd()
        Write-Host "Response body:" -ForegroundColor DarkYellow
        Write-Host $text
      }
    } catch {}
    throw
  }
}

# ==========================
# 1) AUTH — Client Credentials
# ==========================
$TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
$tokenForm = @{
  client_id     = $ClientId
  client_secret = $ClientSecret
  scope         = "https://graph.microsoft.com/.default"
  grant_type    = "client_credentials"
}
$auth = Invoke-RestMethod -Method Post -Uri $TokenEndpoint -Body $tokenForm -ContentType 'application/x-www-form-urlencoded'
if (-not $auth.access_token) { throw "No access_token returned. Ensure CloudPC.ReadWrite.All (+ CloudPC.Read.All) are admin-consented." }

$Headers = @{
  Authorization = "Bearer $($auth.access_token)"
  "Content-Type" = "application/json"
  "Prefer"       = "include-unknown-enum-members"
}

# Base paths
$PoliciesPath = "/beta/deviceManagement/virtualEndpoint/provisioningPolicies"
$CloudPcsPath = "/beta/deviceManagement/virtualEndpoint/cloudPCs"

# ==========================
# 2) Ensure (Get-or-Create) the policy
# ==========================
$escapedName = $DisplayName.Replace("'","''")
$findUri     = New-GraphUri -Path $PoliciesPath -Query @{ '$filter' = "displayName eq '$escapedName'" }

Write-Host "Finding policy by name: $DisplayName" -ForegroundColor Cyan
try { $existing = Invoke-Graph -Method GET -Uri $findUri -Headers $Headers }
catch { Write-Warning "Name lookup failed; proceeding to create."; $existing = $null }

if ($existing -and $existing.value -and $existing.value.Count -gt 0) {
  $policy   = $existing.value | Select-Object -First 1
  $policyId = $policy.id
  Write-Host "Using existing policy '$DisplayName' (id: $policyId)" -ForegroundColor Yellow
} else {
  $createBody = @{
    "@odata.type"           = "#microsoft.graph.cloudPcProvisioningPolicy"
    displayName             = $DisplayName
    description             = $Description
    enableSingleSignOn      = $EnableSSO
    imageType               = $ImageType
    imageId                 = $ImageId
    managedBy               = "windows365"
    microsoftManagedDesktop = @{
      "@odata.type" = "microsoft.graph.microsoftManagedDesktop"
      managedType   = "notManaged"
    }
    windowsSetting          = @{
      "@odata.type" = "microsoft.graph.cloudPcWindowsSetting"
      locale        = $Locale
    }
    windowsSettings         = @{
      "@odata.type" = "microsoft.graph.cloudPcWindowsSettings"
      language      = $Language
    }
    userExperienceType      = "cloudApp"
    provisioningType        = "sharedByEntraGroup"
    domainJoinConfigurations = @(
      @{
        "@odata.type"  = "microsoft.graph.cloudPcDomainJoinConfiguration"
        domainJoinType = "azureADJoin"
        type           = "azureADJoin"
        regionName     = $RegionName
      }
    )
  }

  $createUri = New-GraphUri -Path $PoliciesPath
  Write-Host "Creating policy '$DisplayName'..." -ForegroundColor Cyan
  $policy   = Invoke-Graph -Method POST -Uri $createUri -Headers $Headers -Body $createBody
  $policyId = $policy.id
  if (-not $policyId) { throw "Create succeeded but no policy id returned." }
  Write-Host "Created policy id: $policyId" -ForegroundColor Green
}

# ==========================
# 3) Assign — group + size + capacity + label
# ==========================
$assignUri = New-GraphUri -Path ($PoliciesPath + "/$policyId/assign")

Write-Host "Clearing existing assignments..." -ForegroundColor DarkGray
Invoke-Graph -Method POST -Uri $assignUri -Headers $Headers -Body @{ assignments = @() } | Out-Null

$assignBody = @{
  assignments = @(
    @{
      target = @{
        "@odata.type"          = "#microsoft.graph.cloudPcManagementGroupAssignmentTarget"
        groupId                = $GroupId
        servicePlanId          = $ServicePlanId
        allotmentLicensesCount = $AllotmentCount
        allotmentDisplayName   = $AllotmentDisplayName
      }
    }
  )
}
Write-Host "Assigning policy to group $GroupId (plan $ServicePlanId, count $AllotmentCount)..." -ForegroundColor Cyan
Invoke-Graph -Method POST -Uri $assignUri -Headers $Headers -Body $assignBody | Out-Null
Write-Host "Assignment submitted." -ForegroundColor Green

# ==========================
# 4) Verify — read policy with $expand=assignments (RETRY)
# ==========================
$expandUri = New-GraphUri -Path ($PoliciesPath + "/$policyId") -Query @{ '$expand' = 'assignments' }
Write-Host "Reading back policy + assignments via $expand..." -ForegroundColor Cyan

$verify = $null
for ($try=1; $try -le 12; $try++) {
  try {
    $verify = Invoke-Graph -Method GET -Uri $expandUri -Headers $Headers
    if ($verify.assignments -and $verify.assignments.Count -gt 0) { break }
    Write-Host "Assignments not materialized yet (attempt $try). Waiting 3s..." -ForegroundColor Yellow
  } catch {
    Write-Warning "Expand read attempt $try failed; retrying in 3s..."
  }
  Start-Sleep -Seconds 3
}
if (-not $verify) { throw "Failed to read policy with assignments after retries." }

$verify | ConvertTo-Json -Depth 20 | Write-Output

# Sanity: confirm the expected assignment
$expected = $verify.assignments | Where-Object {
  $_.target.groupId -eq $GroupId -and
  $_.target.servicePlanId -eq $ServicePlanId -and
  $_.target.allotmentLicensesCount -eq $AllotmentCount -and
  ($_.target.allotmentDisplayName -eq $AllotmentDisplayName -or -not $AllotmentDisplayName)
}
if ($expected) {
  Write-Host "✅ Assignment present with expected group, plan, and count." -ForegroundColor Green
} else {
  Write-Warning "Assignment not found with expected fields. See dump above."
}

# ==========================
# 5) (Optional) Poll for provisioned Cloud PCs for this policy
# ==========================
if ($VerifyDesiredCount -gt 0) {
  $cloudPcsUri = New-GraphUri -Path $CloudPcsPath -Query @{ '$filter' = "provisioningPolicyId eq '$policyId'" }
  for ($i = 1; $i -le $VerifyMaxTries; $i++) {
    try {
      $cloudPcs   = Invoke-Graph -Method GET -Uri $cloudPcsUri -Headers $Headers
      $pcsForPlan = $cloudPcs.value | Where-Object { $_.servicePlanId -eq $ServicePlanId -or -not $_.psobject.Properties.Name.Contains('servicePlanId') }
      $count      = ($pcsForPlan | Measure-Object).Count
      if ($count -ge $VerifyDesiredCount) {
        Write-Host "Provisioned Cloud PCs for policy: $count (target $VerifyDesiredCount) ✅" -ForegroundColor Green
        $pcsForPlan | ConvertTo-Json -Depth 10 | Write-Output
        break
      } else {
        Write-Host "Provisioned Cloud PCs for policy: $count (waiting for $VerifyDesiredCount) … attempt $i/$VerifyMaxTries" -ForegroundColor Yellow
      }
    } catch {
      Write-Warning "Provisioning check failed on attempt ${i}: $($_.Exception.Message)"
    }
    Start-Sleep -Seconds $VerifyDelaySec
  }
}

GitHub Link – avdwin365mem/W365-CloudApp-Prov-Policy at main · askaresh/avdwin365mem

Provisioning Policy Details – UI

Policy

Overview

Tips, gotchas, and troubleshooting

  • App discovery: Ensure the app has a Start menu shortcut on the image. That’s how Cloud Apps gets its list.
  • Security baselines: If your tenant enforces restrictions on PowerShell in the image at discovery time, discovery can fail.
  • MSIX/Appx: Discovery is expanding—classic installers show up first; some Appx/MSIX apps (e.g., newer Teams) may not appear yet.
  • Concurrency math: Active sessions for the policy are capped by assigned Frontline license count on that policy.
  • Schema drift: These are beta endpoints. If you hit a property/enum change, the script’s warnings will surface the response body—update the field names accordingly.

What’s next (Part 2)

We’ll move to All Cloud Apps to publish the discovered apps, tweak display name/description/command line/icon index, confirm they appear in Windows App, and cover unpublish/reset workflows—with your screenshots.

I hope you find this helpful information for creating a Cloud App using PowerShell. If I have missed any steps or details, I will be happy to update the post.

Thanks,
Aresh Sarkari