Python实现提醒iOS描述文件有效期

背景

公司企业APP描述文件过期,没有提醒,导致当天出现不可用的问题。

为了避免再发生类似的问题,笔者想要写一个Python脚本,读取描述文件,获取有效期,设置提醒,且自动运行。

实现

首先再来理一下思路,所有的描述文件都在~/Library/MobileDevice/Provisioning Profiles/目录下,但是里面的内容通常不会自动删除,过期的或者重复的都在这个目录中,而且这个目录下的文件名是uuid命名的和Xcode中的文件名字也不能直接对应,所以一眼看去,只能用一个字形容:乱。

如果账号是管理员,直接登录在电脑上,项目中用的自动管理描述文件的,还好一些,现在会自动续期。但是如果账号是开发者,发布的描述文件没有权限用自动管理的,就需要注意这个描述文件有效期的问题。

再来理一下思路,想要的是一个读取描述文件夹下所有描述文件,获取描述文件中的内容,根据有效期,设置提醒,且自动运行的脚本。

那这里面最重要的是什么?是获取描述文件的内容,这关系到这个思路是否可行。

获取描述文件的内容

首先,来看下,描述文件的格式是uuid.mobileprovision,而这个.mobileprovision格式默认是直接安装到 Xcode 的,通过预览可以看到里面的内容。但是用脚本如何读取里面的内容呢?

首先用VSCode打开一个这样的描述文件,提示如下:

点击Open Anyway,然后选择用Text Editor的方式打开,可以看到,文件的开始和结束都是一堆乱码,中间却是一段plist格式的内容,如下:

所以猜想可以通过读取文件内容,截取开始和结束字符串,生成Plist文件。然后再通过读取 Plist 文件并解析获取对应的属性的内容。

下面来一步步尝试实现:

首先是读取文件内容,出师不利,在这一步就遇到了困难,设置encodingutf-8,通过 open 读取到的文件内容一直为空,排查了好久。一开始以为是encoding指定的不对,调试后发现,设置errors='ignore'即可。

1
2
3
4
5
6
# read mobileprovision file content
def readMobileProvisionContent(fileName):
fileFullName = fileName + ".mobileprovision"
with open(fileFullName, "r", encoding="utf-8", errors="ignore") as file:
fileContent = file.read()
return fileContent

截取字符串,生成新的Plist格式的文件

获取到文件内容之后,下一步是截取指定字符串之间的内容,生成新的Plist格式的文件。根据上面的用文本格式查看.mobileprovision内容的分析,需要截取的内容是<?xml </plist>之间的内容,然后生成新的文件。这里需要注意的是,查找到结束位置时,获取到的Location,需要加上</plist>的长度才是完整的内容,详细代码如下:

Ps:这里走了一部分弯路,一开始转为XML格式的文件,生成后内容的读取并不方便,后来发现直接转为Plist格式的读取内容更为快捷。

1
2
3
4
5
6
7
8
9
# 获取指定字符串之间的内容
def getSubContentBetween(startStr, endStr, sourceStr):
startLoc = sourceStr.find(startStr)
if startLoc >= 0:
endLoc = sourceStr.find(endStr, startLoc)
if endLoc >= 0:
# 这里需要注意,获取到的 endLoc 需要加上 endStr 的长度才是完整的内容
endLoc += len(endStr)
return sourceStr[startLoc:endLoc].strip()

接下来是用获取到的内容,生成Plist文件。在这里需要注意写入的方式,要用覆盖写入的方式,而不是拼接写入,防止多次执行出现问题。具体代码如下:

1
2
3
4
5
6
7
8
9
# 根据字符串内容生成 plist 文件
def generatePlistFile(fileName, fileContent):
fileFullName = fileName + '.plist'
# 需要注意,mode为打开文件的方式,a 为追加,r 为只读,w 为覆盖
file = open(fileFullName, mode='w', encoding='utf-8')
for i in range(len(fileContent)):
text = fileContent[i]
file.write(text)
file.close()

生成 Plist 文件后,接下来是解析 Plist 文件内容,获取到描述文件名字、有效期、UUID 等信息,下面具体来看看:

解析 Plist 文件

在解析Plist之前,需要思考一下,具体需要获取哪些字段,最终目的是提醒,所以过期日期字段是一定要解析的。然后需要考虑提醒的问题,是添加日历提醒,还是通过生成一个Excel 或者 html 的表格文件,用不同颜色区分不同有效期。这里用第二种生成 Excel 或者 html 的方式。

接下来需要考虑的就是显示哪些字段:

由于描述文件的名字中显示的是 UUID.mobileprovision,和 Xcode 中配置的不同,Xcode 中显示的是名字,所以名字和UUID都要显示出来,用于一一对应。

然后是描述文件对应的bundleID,用于确认具体的APP。

再然后是有效期相关信信息,CreationDateExpirationDate,以及计算出来的剩余天数。

Name UUID bundleID CreationDate ExpirationDate 剩余过期天数
单元格 单元格 单元格 单元格 单元格 单元格

解析Plist使用Pythonplistlib库,日期计算使用datetime库,都不需要额外安装,直接导入使用,具体代码如下:

Ps:

  1. 解析出来的CreationDateExpirationDate都是 date 类型,而不是 string 类型。
  2. open file的 mode 需要指定为rb,如果指定为r,则会提示TypeError: startswith first arg must be str or a tuple of str, not bytes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

# 解析Plist文件内容
import plistlib # 解析Plist需要的库
import datetime # 日期计算需要的库

def parsePlistInfo(fileName):
fileFullName = fileName + ".plist"
# 注意下面的mode需要为`rb`,读取 plist 内容
with open(fileFullName, mode='rb') as plist:
plistDic = plistlib.load(plist)

name = plistDic["Name"]
uuid = plistDic["UUID"]
creationDate = plistDic["CreationDate"]
expirationDate = plistDic["ExpirationDate"]

entitlements = plistDic["Entitlements"]
applicationIdentifier = entitlements["application-identifier"]
teamIdentifier = entitlements["com.apple.developer.team-identifier"]
bundleIDLoc = applicationIdentifier.find(teamIdentifier) + len(teamIdentifier) + 1
bundleID = applicationIdentifier[bundleIDLoc:].strip()

currentDate = datetime.datetime.now()
dateDelta = expirationDate - currentDate
leftDays = dateDelta.days

dateformatterStr = "%Y %m %d %H:%M:%S"
creationDateStr = creationDate.strftime(dateformatterStr)
expirationDateStr = expirationDate.strftime(dateformatterStr)

return (name, uuid, bundleID, creationDateStr, expirationDateStr, leftDays)

到这一步说明之前的思路是可行的,即读取描述文件xxx.mobileprovision的内容,生成新的plist格式的文件,然后再通过读取plist的content获取对应属性的值,并计算到期日期。

最后需要考虑的是设置提醒的逻辑,起初打算直接写入 Mac 日历,调研后发现能做到的是生成日历格式的文件,然后手动导入。所以改为生成一个 htmlExcel 文件,对快过期和已过期的标红显示,然后自动发送到邮箱(在这里实现为直接打开)。下面来看一下生成htmlExcel的逻辑。

生成 htmlExcel 文件

在生成之前需要考虑哪些状态是需要标红显示的:如果剩余天数小于 0,说明已过期;如果剩余天数小于 30,说明一个月内过期,这两种可以高亮显示;如果大于 30,则说明有效期大于 1 个月,只需要正常显示即可。

生成html文件

先来看下生成 html 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def writeToHtml(infoTurple, filepath):
htmlPath = filepath + "iOS描述文件统计.html"

columnTitles = ['name', 'uuid', 'bundleID', '创建日期', '过期日期', '有效期']
fileout = open(htmlPath, 'w')
table = "<table border='1' width='70%'>\n"

# create table column headers
table += " <tr>\n"
for title in columnTitles:
table += " <th>{0}</th>\n".format(title.strip())
table += " </tr>\n"

# Create the table's row data
columnCount = len(infoTurple)
table += " <tr>\n"
for i, x in enumerate(infoTurple):
if i == columnCount - 1:
color = '#FFFFFF'
valueStr = str(x) + "天内过期"
if x < 0:
valueStr = "已过期"
color = '#FF0000'
elif x < 30:
valueStr = str(x) + "天内过期"
color = '#FFF000'
else:
valueStr = "还有" + str(x) + "天过期"
table += " <td bgcolor='{bgcolor}'>{value}</td>\n".format(bgcolor=color, value=valueStr)
else:
table += " <td>{0}</td>\n".format(x)
table += " </tr>\n"
table += "</table>"

fileout.writelines(table)
fileout.close()

运行后显示效果,如下图所示:

python4

生成Excel文件

再来看一下,如何生成 Excel 格式的文件,毕竟如果要发送给他人,Excel格式的比html的更正式点。

生成Excel格式的文件,需要安装三方库,这里使用的是openpyxl,首先用如下命令安装:

1
pip3 install openpyxl

然后生成Excel的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from openpyxl import Workbook
from openpyxl.styles import PatternFill
from openpyxl.styles.colors import Color

# 生成 Excel 文件
def writeToExcel(infoTurple, filepath):
excelPath = filepath + "iOS描述文件统计.xlsx"

wb = Workbook()
ws = wb.active
ws.title = '描述文件信息'
columnTitles = ['name', 'uuid', 'bundleID', '创建日期', '过期日期', '有效期']
for i, x in enumerate(columnTitles):
c1 = ws.cell(row = 1, column = i + 1)
c1.value = x

count = len(infoTurple)
for i, x in enumerate(infoTurple):
columnIndex = i + 1
c2 = ws.cell(2, column = columnIndex)
if columnIndex == count:
color = '00FFFFFF'
if x < 0:
c2.value = "已过期"
color = '00FF0000'
elif x < 30:
color = '00FFF000'
c2.value = str(x) + "天内过期"
else:
c2.value = "还有" + str(x) + "天过期"
color = '00FFFFFF'
c2.fill = PatternFill(patternType='solid',fgColor=color)
else:
c2.value = x
wb.save(excelPath)

运行后效果如下:

python5

自动打开最终生成的文件

最终生成的文件,可以通过脚本发送给相关人,或者直接打开以达到提醒的效果。这里用的是直接打开。具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13

import platform
import subprocess

def openFile(fullFilePath):
systemType = platform.platform() # 获取系统类型
if 'mac' in systemType:
fullFilePath = fullFilePath.replace('\\', '/') # mac 下,遇到"\\"的路径打不开
subprocess.call(["open", fullFilePath])
else:
fullFilePath = fullFilePath.replace("/", "\\") # win 下,遇到"/"的路径打不开
os.startfile(fullFilePath)

整体处理

截止到这一步,针对单个描述文件的处理已经完成,即对单个描述文件,解析内容并生成可视化提醒的一整套逻辑都已经实现。下面需要考虑的是另外三个方面:

  1. 批量处理逻辑:熟悉 iOS 开发的都知道,描述文件是存放在打包机或者自己电脑上的~/Library/MobileDevice/Provisioning Profiles/中,里面存放了许多描述文件,所以下一步首先要考虑的是批量扫描处理的逻辑。
  2. 过期自动删除的逻辑:这个可以说是一个feature,因为~/Library/MobileDevice/Provisioning Profiles/这个目录下,如果没有清理过,可能存在很多已过期的文件,所以既然能获取到这个文件是否已过期,那么就能实现已过期的文件直接删除,但是这一步是可选,取决于自己是否需要。
  3. 重复文件自动标记的逻辑:因为描述文件所在的目录中可能会存在多个描述文件有同样的名字、同样的bundleID,都有效,但有效期不同的情况;这种情况可能会出现打包的时候不同版本用了不同的描述文件,从而导致 APP 不同版本有不同的有效期。所以针对这种情况,需要把名字重复的也添加高亮标记提醒,然后手动进行确认处理。

批量处理需要注意的是,由于描述文件所在目录~/Library/MobileDevice/Provisioning Profiles/是相对路径,需要转为绝对路径再打开。脚本所在目录就没有限制,不需要和描述文件放在同一个文件夹也可。

再来思考一下整体处理的思路:

  1. 打开描述文件所在文件夹
  2. 遍历读取每个描述文件
  3. 针对每个描述文件进行如下处理:
    1. 读取描述文件内容
    2. 截取开始和结束字符串,生成新的 Plist 文件,放入暂存文件夹中
    3. 读取 Plist 文件,获取指定字段的值
    4. 存储读取到的内容到指定数组
    5. 在写入过程中,存储之前每步写入的文件名;如果发现当前文件名在已写入的数组,说明是重复文件,则把当前文件名放入重复文件数组中。
    6. 根据剩余有效期,判断文件是否过期,存储已过期的文件UUID到过期数组
  4. 遍历数组将读取到的内容写入最终生成的文件
    1. 在写入过程中,判断剩余有效期,针对快过期和已过期的做标记显示
    2. 如果发现当前文件名在重复文件的数组中,则对当前文件名做标记显示
  5. 根据需要,遍历过期数组,删除每个过期的描述文件
  6. 删除暂存文件夹中生成的所有 Plist 文件
  7. 打开最终生成的Excelhtml文件

整体处理的完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
import plistlib
import datetime
import os
from openpyxl import Workbook
from openpyxl.styles import PatternFill
from openpyxl.styles.colors import Color
import shutil
import platform
import subprocess

# read mobileprovision file content
def readMobileProvisionContent(fileName):
fileFullName = fileName + ".mobileprovision"
with open(fileFullName, "r", encoding="utf-8", errors="ignore") as file:
fileContent = file.read()
return fileContent

# 获取指定字符串之间的内容
def getSubContentBetween(startStr, endStr, sourceStr):
startLoc = sourceStr.find(startStr)
if startLoc >= 0:
endLoc = sourceStr.find(endStr, startLoc)
if endLoc >= 0:
# 这里需要注意,获取到的 endLoc 需要加上 endStr 的长度才是完整的内容
endLoc += len(endStr)
return sourceStr[startLoc:endLoc].strip()

# 根据字符串内容生成 plist 文件
def generatePlistFile(fileName, fileContent):
fileFullName = fileName + '.plist'
# 需要注意,mode为打开文件的方式,a 为追加,r 为只读,w 为覆盖
file = open(fileFullName, mode='w', encoding='utf-8')
for i in range(len(fileContent)):
text = fileContent[i]
file.write(text)
file.close()


def parsePlistInfo(fileName):
fileFullName = fileName + ".plist"
# 注意下面的mode需要为`rb`,读取 plist 内容
with open(fileFullName, mode='rb') as plist:
plistDic = plistlib.load(plist)

name = plistDic["Name"]
uuid = plistDic["UUID"]
creationDate = plistDic["CreationDate"]
expirationDate = plistDic["ExpirationDate"]

entitlements = plistDic["Entitlements"]
applicationIdentifier = entitlements["application-identifier"]
teamIdentifier = entitlements["com.apple.developer.team-identifier"]
bundleIDLoc = applicationIdentifier.find(teamIdentifier) + len(teamIdentifier) + 1
bundleID = applicationIdentifier[bundleIDLoc:].strip()

currentDate = datetime.datetime.now()
dateDelta = expirationDate - currentDate
leftDays = dateDelta.days

dateformatterStr = "%Y-%m-%d %H:%M:%S"
creationDateStr = creationDate.strftime(dateformatterStr)
expirationDateStr = expirationDate.strftime(dateformatterStr)

return (name, uuid, bundleID, creationDateStr, expirationDateStr, leftDays)

def writeToHtml(infoTurpleList, repeatNameList, filepath):
htmlPath = filepath + "iOS描述文件统计.html"

columnTitles = ['name', 'uuid', 'bundleID', '创建日期', '过期日期', '有效期']
fileout = open(htmlPath, 'w')
table = "<table border='1' width='70%'>\n"

# create table column headers
table += " <tr>\n"
for title in columnTitles:
table += " <th>{0}</th>\n".format(title.strip())
table += " </tr>\n"

# Create the table's row data
for row, infoTurple in enumerate(infoTurpleList):
columnCount = len(infoTurple)
table += " <tr>\n"
for i, x in enumerate(infoTurple):
color = '#FFFFFF'
valueStr = x
if i == 0:
# 说明是名字
if x in repeatNameList:
color = '#FFF000'
table += " <td bgcolor='{bgcolor}'>{value}</td>\n".format(bgcolor=color, value=valueStr)
elif i == columnCount - 1:
if x < 0:
valueStr = "已过期"
color = '#FF0000'
elif x < 30:
valueStr = str(x) + "天内过期"
color = '#FFF000'
else:
valueStr = "还有" + str(x) + "天过期"
table += " <td bgcolor='{bgcolor}'>{value}</td>\n".format(bgcolor=color, value=valueStr)
else:
table += " <td>{0}</td>\n".format(x)
table += " </tr>\n"
table += "</table>"

fileout.writelines(table)
fileout.close()

# 生成 Excel 文件
def writeToExcel(infoTurpleList, repeatNameList, filepath):
excelPath = filepath + "iOS描述文件统计.xlsx"

wb = Workbook()
ws = wb.active
ws.title = '描述文件信息'
columnTitles = ['name', 'uuid', 'bundleID', '创建日期', '过期日期', '有效期']
for i, x in enumerate(columnTitles):
c1 = ws.cell(row = 1, column = i + 1)
c1.value = x

for row, infoTurple in enumerate(infoTurpleList):
count = len(infoTurple)
for i, x in enumerate(infoTurple):
columnIndex = i + 1
cellColumn = ws.cell(row = row + 2, column = columnIndex)

if i == 0:
# 说明是名字
cellColumn.value = x
if x in repeatNameList:
cellColumn.fill = PatternFill(patternType='solid',fgColor='00FFF000')
elif columnIndex == count:
color = '00FFFFFF'
if x < 0:
cellColumn.value = "已过期"
color = '00FF0000'
elif x < 30:
color = '00FFF000'
cellColumn.value = str(x) + "天内过期"
else:
cellColumn.value = "还有" + str(x) + "天过期"
color = '00FFFFFF'
cellColumn.fill = PatternFill(patternType='solid',fgColor=color)
else:
cellColumn.value = x
wb.save(excelPath)

# 创建目录
def createDir(fileDir):
if not os.path.exists(fileDir):
os.mkdir(fileDir)

# 打开文件
def openFile(fullFilePath):
systemType = platform.platform() # 获取系统类型
if 'mac' in systemType:
fullFilePath = fullFilePath.replace('\\', '/') # mac 下,遇到"\\"的路径打不开
subprocess.call(["open", fullFilePath])
else:
fullFilePath = fullFilePath.replace("/", "\\") # win 下,遇到"/"的路径打不开
os.startfile(fullFilePath)

# 获取指定目录下,所有描述文件的名字
def findAllMobileprovision(filePath):
resultList = []
for root, ds, fs in os.walk(filePath):
targetFileExt = '.mobileprovision'
for f in fs:
if f.endswith(targetFileExt):
fullProfilePath = os.path.join(root, f).replace(targetFileExt, '')
resultList.append(fullProfilePath)
return resultList

# 删除
def delExpiredMobileProvision(fileDir):
for idx, file in enumerate(fileDir):
targetFullPath = file + '.mobileprovision'
if os.path.exists(targetFullPath):
os.remove(targetFullPath)
print("已删除: " + targetFullPath)
else:
print('文件不存在: ' + targetFullPath)

def main():
# 最终生成文件的目录
resultFilePath = '/Users/xxx/Desktop/TempProfilePath/'
# 描述文件的目录
mobileProvisionPath = '/Users/xxx/Library/MobileDevice/Provisioning Profiles/'
# 过程中生成plist文件的目录
tempPlistDir = resultFilePath + 'Plist/'
# 创建最终文件的文件夹
createDir(resultFilePath)
# 创建暂存 Plist 的文件夹
createDir(tempPlistDir)
# 获取所有的描述文件名字
mobileProfisionList = findAllMobileprovision(mobileProvisionPath)

# 存储解析出Plist信息的数组
plistInfoList = []
# 存储过期文件名的数组
expiredList = []
# PlistName数组
plistInfoNameList = []
# 存储重复文件名的数组
repeatNameList = []
for idx, fileName in enumerate(mobileProfisionList):
# 查找内容开始标志
startTag = '<?xml '
# 查找内容结束标志
endTag = '</plist>'
# 读取文件内容
contentStr = readMobileProvisionContent(fileName)
# 获取开始标志和结束标志之间的字符串
plistContent = getSubContentBetween(startTag, endTag, contentStr)
# 获取要写入的Plist路径
plistPath = fileName.replace(mobileProvisionPath, tempPlistDir)
# 生成Plist文件
generatePlistFile(plistPath, plistContent)
# 解析plist文件
plistInfo = parsePlistInfo(plistPath)

# 存储到 Plist 信息数组
plistInfoList.append(plistInfo)

# 判断是否重复
plistName = plistInfo[0]
if plistName in plistInfoNameList:
repeatNameList.append(plistName)
else:
plistInfoNameList.append(plistName)

# 判断是否过期
if plistInfo[5] < 0:
expiredList.append(fileName)

# 生成html文件
writeToHtml(plistInfoList, repeatNameList, resultFilePath)
# 生成Excel文件
writeToExcel(plistInfoList, repeatNameList, resultFilePath)
# # 删除所有过期文件
# delExpiredMobileProvision(expiredList)
# 删除生成的Plist文件夹
shutil.rmtree(tempPlistDir)
# 打开生成的文件
openFile(resultFilePath + 'iOS描述文件统计.xlsx')
openFile(resultFilePath + 'iOS描述文件统计.html')

if __name__ == '__main__':
main()

自动运行

还差最后一步,设置脚本自动运行,参考mac 自动执行python项目,根据需求设置脚本每隔多久自动运行即可。

总结

再来回顾一下整体的处理逻辑,由于原有的描述文件分析查看不方便,所以想要通过脚本读取描述文件内容,生成一种便于阅读的格式,并用于提醒。

首先做的是针对单个描述文件验证,这种思路是否可行,通过读取文件、截取文件内容、生成新的便于处理格式,获取想要的信息,最终生成便于阅读的格式。

单个文件的处理通过验证,发现可行后,再来做针对整个描述文件夹的处理:通过扫描文件夹,然后针对文件夹中的每个文件都做如上处理,并添加过期和重复的处理逻辑,把最终的信息拼接到一起,即是对所有文件的处理逻辑。

最后再通过设置定时运行,来达到提醒的目的,还可以通过发送邮件,定时提醒相关人员,感兴趣的可以自己实现。

整体的流程大致如上,流程不太复杂,但处理稍微有点绕,网上并没有类似的处理方案,所以这里记录分享出来,供大家参考。

参考