Welcome to day 20 of Let’s Git Commit(ted) to </Dev> Resources! A common desire for learning developer-oriented skills, such as working with APIs, is to automate commonly reoccurring tasks. Automating tasks provides an ROI by reducing the time and errors from your IT administrators completing tedious tasks and enables them to focus on business-critical tasks. Automation also reduces the friction of adopting better practices, such as frequent application patching, to reduce device risk and increase employee satisfaction.
The topic for today will focus on using the skills you have developed in your Git Commit(ted) journey to automate a common task: Uploading Windows apps using the Workspace ONE Unified Endpoint Management (UEM) REST APIs! You will utilize the Mobile Application Management (MAM) APIs to accomplish the following task:
- Optionally upload a local or remote image to use for the app icon
- Upload the application binary to Workspace ONE UEM
- Create an Internal Application using the uploaded binary
EUC-Samples to the Rescue!
Mike Nelson has an App Upload sample in the euc-samples GitHub repository available here. The UploadApp.ps1 PowerShell sample is a fantastic starting point for automating this process and I will be referencing and using this sample to demonstrate the APIs used for uploading and creating Windows applications.
EUC-Samples Setup
If you are using the UploadApp.ps1 sample referenced above, note that I have completed the following setup steps:
- I’ve cloned the euc-samples repository and navigated to the euc-samples/UEM-Samples/Utilities and Tools/Generic/AppUpload directory. Feel free to make a copy of the UploadApp.ps1 and Templates directory if you wish to keep the original files for reference.
- I opened the UploadApp.ps1 file and edited the following variables at the top of the script:
- $UserName: Provide an administrator account username that has permission to manage applications and API privileges.
- $Password: Provide the administrator account password.
- $ApiKey: Provide the API Key that will be used to authorize the API calls. The API Key is available from the Workspace ONE UEM administrator console under Groups & Settings > All Settings > System > Advanced > API > REST API.
- $OrgGroupId: This sample sets the OrgGroupId to a particular org group for each execution. This can be obtained in a few ways:
- In the Workspace ONE UEM administrator console, navigate to the organization group you wish to upload the application to. Then navigate to Groups & Settings > Groups > Organization Groups > Details. The URL of this page is in the following format:
https://{host}/AirWatch/#/OrganizationGroup/Details/Index/###
. The integer value at the end of the URL is your OrgGroupId. - You can use the REST APIs to query the IDs of organization groups. The
GET system/groups/search?name={orgGroupName}
API returns a list of organization groups that contains the provided name value. The OrgGroupId for each result is listed in the Id object under the Value property.
- In the Workspace ONE UEM administrator console, navigate to the organization group you wish to upload the application to. Then navigate to Groups & Settings > Groups > Organization Groups > Details. The URL of this page is in the following format:
- $ServerURL: Provide the REST API URL for your environment without the trailing
/api
. You can retrieve the REST API URL through the Workspace ONE UEM administrator console by navigating to Groups & Settings > All Settings > System > Advanced > Site URLs. Find the REST API URL field and copy the FQDN without the trailing/api
path, such as https://cn135.awmdm.com. - $AppFilePath: Provide the full file path to an application binary (.exe, .msi) you wish to upload to Workspace ONE UEM.
- $AppMetaDataFilePath: I recommend making a copy of the sample.json file found in the /App Upload/Templates directory and naming it after the app you wish to upload, such as 7zip.json for the 7-Zip application. Since each individual application will have slightly modified metadata, such as the BlobId and HowToInstall parameters (more on these later), it’s worth separating them into unique files while you are learning the APIs and using the provided UploadApp.ps1 sample.
- (OPTINAL) $AppIconFilePath and $AppIconFileLink: Optionally provide a local image path using $AppIconFilePath OR provide a remote image URL using $AppIconFileLink. The script will not use both and prioritizes the $AppIconFilePath, so be sure to only populate the variable you intend to use.
I use the following files for my walkthrough:
- Notepad++ 8.2 Installer: The latest Notepad++ installer binary at the time of writing is 8.2, which is available here.
- Notepad++ png icon: A copy of the Notepad++ icon downloaded locally as a .png.
- sample.json from the euc-samples /App Upload/Templates directory. I’ve duplicated this file and renamed it npp.json.
- UploadApp.ps1 from the euc-samples App Upload directory as mentioned above. I’ve filled out the variables at the top of the script as described in step 2 above.
- My $AppFilePath points to the Notepad++ 8.2 installer that I downloaded.
- My $AppMetaDataFilePath points to the npp.json file that I duplicated from the sample.json file.
- My $AppIconFilePath points to a local copy of the Notepad++ icon (in .png format) that I downloaded.
- For reference, my UploadApp.ps1 variables are set as the following:
Figure 1: UploadApp.ps1 variables
Here are the UploadApp.ps1 variables from Figure 1 in copyable format:
|
Task 1: Optionally Upload the Local App Icon
To give our application an icon, we need to upload the local app icon as a blob, which allows UEM to reference this blob file as the icon associated with Notepad++. The following logic accomplishes this task:
- Load the image file bytes into a byte array.
- See UploadApp.ps1 lines 179 – 189 for an example.
- Call the POST /api/mam/blobs/uploadblob API to load the file as a blob into UEM.
- See UploadApp.ps1 lines 194-199 and the UploadBlob function as examples.
- Upon success, the response returns a
uuid
property for the blob that needs to be saved to reference in a later step. This associates the uploaded image file as our app icon when we upload the app binary to UEM.- See UploadApp.ps1 line 202 and the response from the UploadBlob function as an example.
Additional points about the POST /api/mam/blobs/uploadblob API:
- Remember that full API documentation can be viewed for your UEM version. The uploadblob API is located under MAM REST API V1 > API Reference > BlobsV1.
- Additional information about the API parameters and supported file types can be found in the API documentation instructions linked above.
- There are two required API parameters: filename and organizationGroupId. Although the other parameters may be optional based on your use case (local file vs. remote file), these two will always be included.
Although the POST /api/mam/blobs/uploadblob API can also be used to upload application files, such as our Notepad++ installer, there is a more efficient API for uploading larger application files by loading chunks of the file at a time. You’ll see this API in action in the next step. However, uploadblob is great for smaller assets, such as our image files.
Task 2: Upload the Local Application Installer
The next step is to upload the application installer file to Workspace ONE UEM. This is similar to the uploadblob process we used for the image, but there is a more efficient API for larger files: POST /mam/apps/internal/uploadchunk.
The uploadchunk API uploads fractions of a file at a time, dictated by the ChunkData property you specify in the post body request. The uploadchunk API is recommended for applications 2GB or larger in size and requires File Storage to be configured. This API is more involved than uploadblob, so let’s break down how it works. The required post body is as follows:
|
- TransactionId is a string that is either empty on the first API request or provides the TransactionId (a GUID value) that was received from the previously completed POST /mam/apps/internal/uploadchunk. This TransactionId is how UEM combines the separately uploaded chunks into a single file once the file is fully uploaded.
- ChunkData is a Base64 encoded string of the byte segment you are sending in this request body. In the above request body example, the application has a total size of 10,000 bytes and the ChunkSize is set to 5,000, so this ChunkData field needs to contain the first 5,000 bytes of the application in a Base64 encoded string.
- ChunkSequenceNumber starts as 1 for each new request and is incremented by 1 for each new chunk of the file that is uploaded until the file has been fully uploaded.
- TotalApplicationSize is the size of your application installer file in bytes.
- ChunkSize is the number of bytes that the current uploadchunk API request is uploading.
In the AppUpload.ps1 example, remember that the $ChunkSize variable set at the top is responsible for determining the ChunkSize of the uploadchunk API request, which by default is set to 20 MB. This means 20 MB chunks of your application installer file will be uploaded at a time and sequenced together until the entire file has been uploaded.
Figure 2: ChunkSize
Here is the code from Figure 2 in copyable format:
|
The following logic accomplishes this task:
- Determine the ChunkSize you would like to use (recommended 20 MB – 100 MB).
- See UploadApp.ps1 line 33 for setting the ChunkSize.
- Using your application’s total size and the chunk size you wish to upload with each request, use the POST /mam/apps/internal/uploadchunk API and format the post body correctly.
- See UploadApp.ps1 lines 249 – 256 for setting up the initial properties (TransactionId is an empty string for the first request, ChunkSequenceNumber is 1, and reading in bytes from your application).
- See UploadApp.ps1 lines 261 -291 and the UploadChunk function to see how to continuously loop through the bytes of your file to upload your chunk size in sequential order. This requires that the TransactionId gets set to the returned TransactionId from the previous uploadchunk API response and that the ChunkSequenceNumber is incremented by 1 each time.
Note: Don’t forget to Base64 encode your byte string for the ChunkData property!
- When the final uploadchunk API is completed and the entire application byte array has been uploaded, save the final transactionId that was returned from the uploadchunk API response. You will use this in the next step to make the application available in the Workspace ONE UEM administration console.
- See UploadApp.ps1 lines 296 – 300 for handling the final uploadchunk API call and setting the transactionId property in the npp.json metadata.
Task 3: Create an Internal Application using the Uploaded Application Binary
The final step is to create an Internal Application in Workspace ONE UEM by using the uploaded application binary file and the optionally uploaded application icon file. For this process, you will use the POST /api/mam/apps/internal/begininstall API and supply the following:
- The final TransactionId of the uploaded application binary file from the uploadchunk API from Task 2
- The blob UUID of the uploaded application image file from the uploadblob API from Task 1 (if completed)
- A JSON object that determines the properties of the application, such as the application name, version, supported models, the install and uninstall command details. You can either use the /mam/apps/internal/begininstall API documentation (API documentation > MAM REST API V1 > API Reference > InternalAppsV1 > begininstall) or the provided sample.json files in the project Templates folder.
The begininstall API will cause the application to be uploaded in the Workspace ONE UEM administration console under Apps & Books > Applications > Native > Internal, where it can be edited or assigned to users and devices.
The JSON object provided to the begininstall API has many properties, so let’s dive through each section to get a better understanding of what is required, what is optional, and what fields will be completed by the UploadApp.ps1 script.
BeginInstall JSON: Application Details
Open the provided sample.json file in the Templates directory.
Figure 3: Application details
Here is the code from Figure 3 in copyable format:
|
Green: Required, but UploadApp.ps1 will fill in details
Blue: Required, must be input manually by user
Yellow: Optional, but UploadApp.ps1 will fill in details (if applicable)
Not highlighted: Optional, must be input manually by the user if desired
The first few lines control the application details. If you inspect an Internal application in Workspace ONE UEM, you will notice that several of these fields map to the fields on the Details tab.
Required properties:
- BlobId or TransactionId: If the uploadblob API was used to upload the application file, you would need to supply the blobId returned from the uploadblob API response. If the uploadchunk API was used to upload the application file, you would need to supply the final transactionId returned from the uploadchunk API response. For UploadApp.ps1, uploadchunk is used so the TransactionId will be populated.
- ApplicationName: The name of the application which appears in Workspace ONE UEM to administrators and users.
- LocationGroupId: The id of the location group where the application is uploaded to in Workspace ONE UEM.
- FileName: The filename of the application installer that was uploaded.
- SupportedProcessorArchitecture: x86 or x64, depending on what processor architecture your uploaded application installer was for.
- IsDependencyFile: A Boolean value indicating whether this is a dependency file or not. Standalone application installers will use false as they are not a dependency file.
BeginInstall JSON: Device Type and Supported Models
Open the provided sample.json file in the Templates directory.
Figure 4: Device type and supported models
Here is the code from Figure 4 in copyable format:
|
Green: Required, but UploadApp.ps1 will fill in details
Blue: Required, must be input manually by user
Yellow: Optional, but UploadApp.ps1 will fill in details (if applicable)
Not highlighted: Optional, must be input manually by the user if desired
The next section determines which devices this application supports, in this case, Windows desktops.
Required properties:
- DeviceType: The ID or Name of the device type of the application. See Appendix A for available values. In this case, since it is a Windows Desktop application, we are using the corresponding DeviceType of WinRT.
- SupportedModels: This object contains an array of Model types which determines which devices can install this application. If more than one device type is supported, you can provide multiple Models in the array. In this case, we only provide a single model which is for the Windows Desktop (ID: 83, Name: Desktop). See Appendix B for available values.
BeginInstall JSON: Deployment Options: When to Install
Open the provided sample.json file in the Templates directory.
Figure 5: Deployment options: when to install
Here is the code from Figure 5 in copyable format:
|
Green: Required, but UploadApp.ps1 will fill in details
Blue: Required, must be input manually by user
Yellow: Optional, but UploadApp.ps1 will fill in details (if applicable)
Not highlighted: Optional, must be input manually by the user if desired
The next major section, DeploymentOptions, informs Workspace ONE UEM of a number of properties. The first section under DeploymentOptions is WhenToInstall, which is a list of requirements that must be satisfied before an install can begin. The necessary properties are already provided in the sample.json, but you can update the values to match any pre-installation requirements you may desire.
Required Properties for WhenToInstall:
- DataContingencies: An array of DataContingencies (such as App Exists, File Exists, Registry Exists, etc.) that are required to be true before the install can start. This property is beyond the scope of this overview, so the sample.json has left this as the default, empty array.
- DiskSpaceRequiredInKb: A pre-determined amount of disk space in kilobytes that is required. Useful if your installer file will download external content that is larger than the posted install file (such as Office365).
- DevicePowerRequired: An integer from 0 – 100 indicating battery power percentage that the device must be at before starting the install. 0 ignores the battery percentage.
- RamRequiredInMb: A pre-determined amount of memory (RAM) in Megabytes that must be present on the device before the install can start. This is useful to restrict lower-end devices from installing applications they cannot run due to memory requirements not being met.
BeginInstall JSON: Deployment Options: How to Install
Open the provided sample.json file in the Templates directory.
Figure 6: Deployment options: how to install
Here is the code from Figure 6 in copyable format:
|
Green: Required, but UploadApp.ps1 will fill in details
Blue: Required, must be input manually by user
Yellow: Optional, but UploadApp.ps1 will fill in details (if applicable)
Not highlighted: Optional, must be input manually by the user if desired
The next DeploymentOptions sub-section is HowToInstall. This informs Workspace ONE UEM of the commands that should be run to install the app and how it should be installed.
Required Properties for HowToInstall:
- InstallContent: Device installs the application for all users on the device, User installs the application for the single user.
- InstallCommand: The command that needs to be run to install the application. In the case of Notepad++, I call the installer filename with /S to indicate a silent install.
- AdminPrivileges: A Boolean value that indicates whether Admin privileges are required for the install (true) or not required (false).
- DeviceRestart: Dictates if the device needs to reboot after the install completes. The options are DoNotRestart, ForceRestart (a forced system-initiated restart), and RestartIfNeeded (a user-initiated restart).
- RetryCount: Determines how many times the install should be re-attempted in the case of failure.
- RetryIntervalInMinutes: Determines how long, in minutes, the process should wait to attempt a reinstall if it fails.
- InstallTimeoutInMinutes: Determines how long, in minutes, the process should attempt to run the installer until we consider the install as a failure. Since silent installers may run into issues that the user cannot interact with and would need to be restarted after a period of time.
- InstallerRebootExitCode and InstallerSuccessExitCode: If the installer provides an exit or success code when the installer cancels/exits or completes, those values can be provided here.
BeginInstall JSON: Deployment Options: When to Call Install Complete
Open the provided sample.json file in the Templates directory.
Figure 7: Deployment options: when to call install complete
Here is the code from Figure 7 in copyable format:
|
Green: Required, but UploadApp.ps1 will fill in details
Blue: Required, must be input manually by user
Yellow: Optional, but UploadApp.ps1 will fill in details (if applicable)
Not highlighted: Optional, must be input manually by the user if desired
The final sub-section of DeploymentOptions is WhenToCallInstallComplete, which informs Workspace ONE UEM if there are additional criteria that should be used to validate that an application installed successfully. There are two options here: DefiningCriteria or UsingCustomScript. Defining Criteria are checks that Workspace ONE UEM provides (App Exists, File Exists, RegistryExists, etc.) where custom scripts allows the admin to upload a script that reports back if the install is successful or not. Custom scripts are beyond the scope of this overview, and we will focus on using DefiningCriteria.
Required Properties for WhenToCallInstallComplete:
- UseAdditionalCriteria: A Boolean value that indicates if you will supply additional criteria for WhenToCallInstallComplete. If you plan to use either DefiningCriteria or UsingCustomScript, this must be true.
- IdentiyApplicationBy: As mentioned before, you can either use DefiningCriteria (Workspace ONE UEM provided logic to identify successful installs) or UsingCustomScript which sends a script you provide that will report back if the install was successful or not.
- CriteriaList: When you use DefiningCriteria for the IdentifyApplicationBy property, you must provide an array of criteria objects in the CriteriaList array. The child objects of the CriteriaList will change based on the associated CriteriaType, which can be one of the following values:
- AppExists: Checks if an application with a given application identifier is installed
- AppDoesNotExist: Checks if an application with a given application identifier is not installed
- FileExists: Checks if a file exists at a given file path
- FileDoesNotExist: Checks if a file does not exist at a given file path
- RegistryExists: Checks if a given registry path exists
- RegistryDoesNotExist: Checks if a given registry path does not exist
- VersionCondition: Each object in the CriteriaList will also contain a VersionCondition which lets you provide the MajorVersion, MinorVersion, RevisionNumber, and BuildNumber properties for comparison checks. An app version is formatted as MajorVersion.MinorVersion.RevisionNumber.BuildNumber (such as w.x.y.z) and the VersionCondition can be one of six values:
- Any
- EqualTo
- GreaterThan
- LessThan
- NotEqualTo
- GreaterThanOrEqualTo
- LessThanOrEqualTo
The CriteriaList array objects change depending on which CriteriaType you provide. For this overview, we have provided two samples:
- sample.json in the Templates directory shows an AppExists criteria example
- The README.md for the Upload App sample shows a FileExists criteria example, which I am also showing in the sample above for Notepad++.
BeginInstall API
Whew! We went over a lot of details with the BeginInstall API, but hopefully that helps shine a light on how the JSON provided for this API maps to the configurations for Internal Apps seen in the Workspace ONE UEM administration console.
Once you have formatted your BeginInstall JSON appropriately, you can use the API to upload the application to Workspace ONE UEM. You can confirm that the application successfully installed by confirming the begininstall API response returned 200 OK with an Id and Uuid, or you can navigate to the Workspace ONE UEM administration console under Apps & Books > Applications > Native > Internal, and confirm the app installed.
Figure 8: Confirming the internal application in Workspace ONE UEM
Conclusion and Next Steps
With the AppUpload.ps1, you have now successfully completed the following tasks to upload an internal application to Workspace ONE UEM:
- Optionally upload an application icon file using the uploadblob MAM API
- Upload the application installer file using the uploadchunk MAM API
- Make the application installer and application icon available as an internal application using the begininstall API
For next steps, check out the following ideas:
- Use the API documentation and check out the MAM REST API V1 /apps/internal/{applicationId}/assignments API to check out how you can create (POST), modify (PUT), or delete (DELETE) assignments so that your intended users and groups get access to the newly installed internal application.
- Use the API documentation to review some of the begininstall properties we did not cover, especially if you are interested in other CriteriaList types or providing custom scripts to confirm installs were successful.
If you enjoyed this API overview and want to see more examples or a deeper dive – let us know!
Appendix A: Device Types
DeviceTypeID Name
1 WindowsMobile
2 Apple
5 Android
6 Athena
8 WindowsPhone
9 WindowsPc
10 AppleOsX
11 WindowsPhone8
12 WinRT
14 AppleTv
15 Qnx
16 ChromeBook
18 ChromeOS
19 IOTDevice
20 IOTGateway
21 Linux
Appendix B: Device Models
DeviceModelID DeviceTypeID Name LabelKey
1 2 iPhone Iphone
2 2 iPad Ipad
3 2 iPod Touch IpodTouch
5 5 Android Android
6 1 WindowsMobile WindowsMobile
8 8 WindowsPhone WindowsPhone
10 9 Windows PC WindowsPc
14 10 MacBook Pro MacBookPro
15 10 MacBook Air MacBookAir
16 10 Mac Mini MacMini
17 11 Windows Phone 8 WindowsPhone8
18 101 QLn220 Qln220
19 101 QLn320 Qln320
20 101 ZT220 ZT220
21 101 ZT230 Zt230
23 101 QLn420 Qln420
24 101 iMZ220 iMZ220
25 101 iMZ320 iMZ320
30 10 iMac iMac
31 10 Mac Pro MacPro
33 14 Apple TV AppleTv
34 101 ZD500R Zd500r
35 10 MacBook MacBook
36 101 ZT210 Zt210
37 15 QNX Qnx
38 102 B-EP4DL-G BEp4dlG
39 103 ADTP1 ADTP1
40 104 RL3 RL3
41 104 RL4 RL4
43 16 Google Chromebook ChromeBook
44 102 B-EP2DL-G BEp2dlG
45 101 ZD500 Zd500
46 101 ZT410 Zt410
47 101 ZT420 Zt420
48 104 RL3e RL3e
49 104 RL4e RL4e
52 101 ZQ510 ZQ510
53 101 ZQ520 ZQ520
54 11 Windows Phone 10 WindowsPhone10
56 104 LP3e Lp3e
57 201 Infinea BluePad InfineaBluePad
58 201 Infinea mPos InfineamPos
59 201 Infinea Tab M InfineaTabM
60 201 Infinea X InfineaX
61 201 Linea Pro 5 LineaPro5
62 201 Linea Pro 6 LineaPro6
63 102 BFV4DGS12QQRWL BFV4DGS12QQRWL
64 102 B-FP3D-GH40-QM-R BFP3DGH40QMR
65 18 ChromeOS ChromeOS
66 201 Infinea X2 InfineaX2
67 201 Infinea X Mini InfineaXMini
68 201 DPP 255 DPP255
69 201 DPP 450 DPP450
70 201 Linea Pro 7 LineaPro7
71 101 ZR638 ZR638
72 105 FP 90 III FP90III
73 101 ZD420 ZD420
74 101 ZD420-HC ZD420HC
75 101 ZD410 ZD410
76 101 ZD410-HC ZD410HC
77 105 TM-T88VI TMT88VI
78 105 TM-T88VI-iHub TMT88VIiHub
79 104 RP2 RP2
80 104 SAV2 SAV2
81 104 RP4 RP4
82 104 SAV4 SAV4
83 12 Desktop Desktop
84 19 IOTDevice IOTDevice
85 20 IOTGateway IOTGateway
86 12 HoloLens HoloLens
87 101 ZQ310 ZQ310
88 101 ZQ320 ZQ320
89 101 ZD620 ZD620
90 101 ZT510 ZT510
91 101 ZT610 ZT610
92 101 ZT620 ZT620
98 21 Linux Linux
99 21 Ubuntu Ubuntu
100 21 Debian Debian
101 21 Linux Mint LinuxMint
102 21 Raspbian Raspbian
103 21 Fedora Fedora
104 21 CentOS CentOS
105 21 Red Hat RedHat
106 21 openSUSE openSUSE
107 21 Gentoo Gentoo
108 21 Slackware Slackware
109 21 Alpine Alpine
110 21 Arch Linux Arch
111 21 Amazon Linux Amazon
112 21 TENS Linux Tens
Appendix C: Agenda
Make sure to check out the other blog posts in our 28-day series:
- Day 1: Let's Git Commit(ted) to Dev Resources
- Day 2: Getting Started with the Workspace ONE UEM REST APIs
- Day 3: Getting Started with the Workspace ONE Access APIs
- Day 4: Getting Started with the VMware Workspace ONE Intelligence APIs
- Day 5: Getting Started with the VMware Horizon REST APIs and VMware PowerCLI
- Day 6: Getting Started with Automating the Unified Access Gateway Deployment
- Day 7: Podcast: Day 0 Onboarding Automation with Scot Curry
- Day 8: Video: Anatomy of the Workspace ONE UEM API
- Day 9: Introduction to using Postman - Part 1
- Day 10: Introduction to using Postman - Part 2
- Day 11: Pro Tips and Tricks - How to be an API Boss
- Day 12: What is OAuth - Learning the Basics
- Day 13: Getting Started with Intelligent Hub Notifications
- Day 14: Git Basics: Getting Git Going
- Day 15: Podcast: Git Commit(ted) to Resources: Customer Spotlight with The Home Depot
- Day 16: Git VMware {code} Samples and Flings
- Day 17: Using paginated requests with Workspace ONE UEM REST APIs
- Day 18: Event Notifications
- Day 19: Overview of Script Samples using PowerCLI for Horizon
- Day 20: Uploading Windows apps using REST APIs
- Day 21: Uploading macOS apps using REST APIs and Admin Assistant
- Day 22: API-based user lifecycle and SCIM
- Day 23: Video: Community Expert Roundtable on Leveraging APIs and Scripting
- Day 24: Video: Exploring the Workspace ONE GitHub Samples Repository
- Day 25: Featured Fling: Forklift for Workspace ONE UEM
- Day 26: Featured VMware {code} Samples for Horizon
- Day 27: Featured Flings for VMware Horizon
- Day 28: Continuing to Focus on </Dev> Resources Page