iPad app distribution workflow suggestions

bapettit
New Contributor

I'm looking for some guidance and best practice ideas. We are a district of about 1000 macs and 200 iPads. The macs we manage with success but we are struggling with how to best distribute apps to iPads. We have a mix of uses - some carts, personally assigned to students, and others devoted to classrooms. Because we don't have a 1:1, having a fluid system for app curation isn't really in place. We struggle with how to best distribute apps. The biggest issue is not having to choose between management and spontaneity. Right now if a teacher even wants a free app:

  1. We check out the app
  2. Get the VPP Managed Distribution License
  3. Confirm it comes into the Apps list in JSS (sometimes it takes a bit)
  4. Change the scope to the appropriate group
  5. Figure out if self-service or push is the best method
  6. Hope it all works

We've had some struggles with push not working well and needing to a bit of "Send Blank Push" to get things to go.

Am I missing anything that could make this easier? I know I haven't provided all our details but I appreciate any thoughts or other ideas anyone has to simplify.

Thanks!

Brad

12 REPLIES 12

jared_f
Valued Contributor

We found it best to do everything Self Service. A lot of users ignore push notifications (i.e. casper.yourcompany.com wants to install an app). Put every app in Self Service on all devices and the users can download the app if needed or use it if it is already on the device.

bapettit
New Contributor

Yes that works great for free apps or when they are needed on demand. It doesn't work well when we have paid apps in small quantities or you need no user interaction (especially in special needs circumstances). Just curious to hear if I others have similar or different interactions.

jhuls
Contributor III

I recently setup two areas with a small number of iPads that get checked out to students. Apple seems to recommend Configurator 2 for something like this but I found DEP and device assigned apps to work best. I have the apps configured to auto-install. It's a slower process at the moment than just zapping each iPad with a usb cable and Configurator but this allows for three things...

  1. If an ipad doesn't get returned, the user can't wipe it with their own config so we will still have an ip address in Casper to track.
  2. There is no computer the departments have to worry about using for Configurator. We give them a generic account to get on the wireless network and the rest handles itself.
  3. Device based applications solved our issues with the previous need to create apple id's to use to get software on iPads. It's worked well. It's not always the quickest in the world if you're putting large apps on the iPad but I'm wondering if macOS Server with caching would help with this.

Not sure if any of that helps but previous to device assigned applications self service was the most reliable but now we can set it to install automatically.

cdenesha
Valued Contributor II

@bapettit You are doing everything as best as you can. I don't quite know why you say Self Service "doesn't work well when we have paid apps in small quantities" - whether SS or Auto Install you are still scoping to a small number of devices.

The big issue I have with Auto Installed apps is Failed Commands, especially the one about app being scheduled for management. The app will not try to install again until that error is cleared and an Update Inventory is sent. When the user requests the app from Self Service, JAMF has coded it to clear the error and try again.

Also, if you know that the VPP Managed Licenses are ready you don't have to wait for Casper to add the app to your Catalog. You can add it yourself and it'll see your licenses.

bapettit
New Contributor

@jhuls Thank you for sharing your set up. I think you are right that a caching server should increase the likelihood of apps making it to their destination.

@cdenesha my struggles with paid apps in self-service center around a lot of high-need students with many apps that are quite expensive and similar and making sure the wrong scope doesn't swallow up a license/install. It's not a hit on self-service as much as management of one-off systems. I like the idea of not waiting on Casper to add the app, my only hesitation in the past was making sure I actually chose the right app if I'm adding it manually. It's not as easy when you are doing 30 different apps that are all named things like "123 Math," "Math 123," "counting math" and "123 counting." :) Thank you for your thoughts!

jared_f
Valued Contributor

@bapettit You can always pull back licenses from the devices.

cdenesha
Valued Contributor II

@bapettit i recently discovered that when you search for an app to add to the catalog, you are next given a results page with three tabs. The third one shows just the matches to your VPP apps!

bapettit
New Contributor

@cdenesha that's a great tip, I wasn't aware of that!

@jared_f yes, being able to pull licenses does help. In practice that sometimes takes a bit of time or requires some poking. Since we rarely ever see these devices once they are in other's hands it is hard to know their current state.

Graeme
Contributor

@cdenesha in my case all our students are special needs and vary greatly so we use a large number of apps but buying licences to cover each student would be very expensive. So just putting everything in self service wont work because JSS assigns a licence to every device that can install the app, not just those that have it installed. Scoping each app to each student individually would be very time consuming and defeat our aim of developing self empowerment and too slow to give the teachers the ability to decide what best fits their student's needs at the time its needed.

The third post in https://www.jamf.com/jamf-nation/feature-requests/4813/assign-device-based-mac-app-licenses-on-demand details a very manual method we use to make all of our mobile device apps available in self service.

jared_f
Valued Contributor

@Graeme I understand where you are coming from... it takes a long time to scope apps to specific users. We came across a script which takes student info from you student info system and scopes apps and books based on class enrollment. We don't use it so I can't sure if it works but, it may be an option to make sure the right kids have what they need.

#!/usr/bin/env python

# Limitations:
# - Only one device per student is supported
# - Classes may not contain a + or / character as the API cannot parse these at this time

# Settings
jssAddr = "https://casper.yourcompany.org:8443" # JSS server address with or without https:// and port
jssUser = "JSSLogin" # JSS login username (API privileges must be set)
jssPass = "Password" # JSS login password
csvPath = "/users/Admin/desktop/jssscript/StudentsByCourse.csv" # Path and file name for the input CSV file
# Imports
from urllib2 import urlopen, URLError, HTTPError, Request
from xml.dom import minidom
from xml.sax import make_parser, ContentHandler
from sys import exc_info, argv, stdout, stdin
from getpass import getpass
import base64
import csv
import re

def main():
    global jssAddr
    global jssUser
    global jssPass
    global csvPath
    updateMDGroups = JSSUpdateMobileDeviceGroups()
    studentList = updateMDGroups.getCSVData(csvPath)
    updateMDGroups.updateGroups(jssAddr, jssUser, jssPass, studentList) 


class JSSUpdateMobileDeviceGroups:
    def __init__(self):
        self.numMobileDevicesUpdated = 0
        self.deviceMap = dict()

    def getCSVData(self, csvPath):
        # Read in CSV
        csvinput = open(csvPath)
        reader = csv.reader(csvinput)
        return reader

    def updateGroups(self, jssAddr, jssUser, jssPass, studentList):
        # Cache mobile device ID to student username mapping
        self.grabMobileDeviceData(jssAddr, jssUser, jssPass)

        # Assign student device to the classes (groups)
        url = jssAddr + "/JSSResource/mobiledevicegroups"
        grabber = CasperGroupPUTHandler(jssUser, jssPass)
        for studentLine in studentList:
            if studentLine[0] != "Student ID":
                self.handleStudentDeviceAssignment(grabber, url, studentLine)
        print "Successfully updated %d devices in the JSS." % self.numMobileDevicesUpdated

    def grabMobileDeviceData(self, jssAddr, jssUser, jssPass):
        url = jssAddr + "/JSSResource/mobiledevices"
        grabber = CasperDeviceXMLGrabber(jssUser, jssPass)
        grabber.parseXML(url, CasperMobileDeviceListHandler(grabber, self.deviceMap))

    def handleStudentDeviceAssignment(self, grabber, url, studentLine):
        # Create mobile device studentLine XML...       
        if studentLine[0] in self.deviceMap:
            if "/" in studentLine[1] or "+" in studentLine[1]:
                print "Error: User: %s, Class: %s, Error: Class contains forward slash or ends in plus character" % (studentLine[0], studentLine[1])
            else:
                studentDeviceID = self.deviceMap[studentLine[0]]
                newGroupAssignment = self.createNewGroupElement(studentDeviceID, studentLine[1])
                self.handleGroupPUT(grabber, url, studentLine[1], newGroupAssignment)
        else:
            print "Error: User: %s, Class: %s, Error: Could not find a mobile device match for student username" % (studentLine[0], studentLine[1])

    def handleGroupPUT(self, grabber, url, className, newGroupAssignment):
        # PUT new XML
        apiClassURLRAW = url + "/name/" + className 
        apiClassURL = apiClassURLRAW.replace (' ', '+')
        apiClassName = className.replace ('+', '')
        apiClassName = apiClassName.replace (' ', '+')
        ###########UNCOMMENT NEXT TWO LINES FOR DEBUG MODE#############
        #print "PUT-ing URL %s: " % (apiClassURL)
        #print newGroupAssignment.toprettyxml()
        putStatus = grabber.openXMLStream("%s/name/%s" % (url, apiClassName), newGroupAssignment)
        if putStatus is None:
            self.numMobileDevicesUpdated += 1

    def createNewGroupElement(self, studentDeviceID, groupName):
        global eventValues
        newGroupAssignment = minidom.Document()
        group = self.appendEmptyElement(newGroupAssignment, newGroupAssignment, "mobile_device_group")
        self.appendNewTextElement(newGroupAssignment, group, "name", groupName)
        groupAdditions = self.appendEmptyElement(newGroupAssignment, group, "mobile_device_additions")
        deviceElement = self.appendEmptyElement(newGroupAssignment, groupAdditions, "mobile_device")
        self.appendNewTextElement(newGroupAssignment, deviceElement, "id", studentDeviceID)
        return newGroupAssignment

    def appendEmptyElement(self, doc, section, newElementTag):
        newElement = doc.createElement(newElementTag)
        section.appendChild(newElement)
        return newElement

    def appendNewTextElement(self, doc, section, newElementTag, newElementValue):
        newElement = self.appendEmptyElement(doc, section, newElementTag)
        newValueElement = doc.createTextNode(newElementValue)
        newElement.appendChild(newValueElement)
        return newElement

class CasperDeviceXMLGrabber:
    def __init__(self, jssUser, jssPass):
        self.jssUser = jssUser
        self.jssPass = jssPass

    def parseXML(self, url, handler):
        req = Request(url)
        base64string = base64.encodestring('%s:%s' % (self.jssUser, self.jssPass))[:-1]
        authheader = "Basic %s" % base64string
        req.add_header("Authorization", authheader)
        try:
            MobileDeviceList = urlopen(req)
        except (URLError, HTTPError) as urlError: # Catch errors related to urlopen()
            print "Error when opening URL: " + urlError.__str__()
            exit(1)
        except: # Catch any unexpected problems and bail out of the program
            print "Unexpected error:", exc_info()[0]
            exit(1)
        parser = make_parser()
        parser.setContentHandler(handler)
        parser.parse(MobileDeviceList)

class CasperGroupPUTHandler:
    def __init__(self, jssUser, jssPass):
        self.jssUser = jssUser
        self.jssPass = jssPass

    def parseXML(self, url):
        return self.openXMLStream(url, None)

    def openXMLStream(self, url, xmlout):
        try:
            if xmlout is None:
                req = Request(url)
            else:
                req = Request(url, data=xmlout.toxml())
                req.add_header('Content-Type', 'text/xml')
                req.get_method = lambda: 'PUT'
            base64string = base64.encodestring('%s:%s' % (self.jssUser, self.jssPass))[:-1]
            authheader = "Basic %s" % base64string
            req.add_header("Authorization", authheader)
            xmldata = urlopen(req)
            if xmlout is None:
                xmldoc = minidom.parse(xmldata)
            else:
                xmldoc = None
            return xmldoc
        except (URLError, HTTPError) as urlError: # Catch errors related to urlopen()
            if urlError.code == 404:
                if xmlout is None:
                    req = Request(url)
                else:
                    req = Request(url, data=xmlout.toxml())
                    req.add_header('Content-Type', 'text/xml')
                    req.get_method = lambda: 'POST'
                base64string = base64.encodestring('%s:%s' % (self.jssUser, self.jssPass))[:-1]
                authheader = "Basic %s" % base64string
                req.add_header("Authorization", authheader)
                xmldata = urlopen(req)
                if xmlout is None:
                    xmldoc = minidom.parse(xmldata)
                else:
                    xmldoc = None
                return xmldoc
            print "Error when opening URL %s - %s " % (url, urlError.__str__())
            return "Error"
        except: # Catch any unexpected problems and bail out of the program
            print "Unexpected error:", exc_info()[0]
            exit(1)

# This class is used to parse the /mobiledevices list to get all of the ids and usernames
class CasperMobileDeviceListHandler(ContentHandler):
    def __init__(self, grabber, deviceMap):
        ContentHandler.__init__(self)
        self.grabber = grabber
        self.deviceMap = deviceMap
        self.currentID = ""
        self.inID = False
        self.inSize = False
        self.inUsername = False

    def startElement(self, tag, attributes):
        if tag == "id":
            self.inID = True
        elif tag == "username":
            self.inUsername = True
        elif tag == "size":
            self.inSize = True

    def endElement(self, tag):
        self.inID = False
        self.inSize = False
        self.inUsername = False
        if tag == "mobiledevices":
            print "Finished collecting mobile devices for lookup"

    def characters(self, data):
        if self.inID:
            self.currentID = data

        elif self.inUsername:
            if data != "":
                self.deviceMap[data] = self.currentID

        elif self.inSize:
            self.numDevices = data
            print "Collecting data for " + data + " Mobile Device(s)..."

if __name__ == "__main__":
    main()

More Info > https://www.jamf.com/jamf-nation/discussions/5505/static-groups-by-class-enrollment

cdenesha
Valued Contributor II

I just posted what we are doing with expensive one-off apps at the link Graeme posted above (clickable link).

Graeme
Contributor

Thanks jarad_f and cdenesha. The script is interesting and we already have one running that makes the classes for Casper Focus but haven't ventured into assigning apps by users. The same script does assign the device to a specific user and sets the device's name appropriately so a transition should be easy.