OTA builder
#!/usr/bin/env python | |
# -*- coding: utf-8 -*- | |
import os | |
import sys | |
import json | |
import getopt | |
import urllib2 | |
import commands | |
import string | |
import qrcode | |
import sys,re | |
from urllib2 import urlopen as U, Request as R | |
from json import loads as J | |
from urllib import urlencode | |
from base64 import b64encode | |
from subprocess import Popen, PIPE, call | |
from datetime import datetime | |
APP_NAME = "" | |
WORKSPACE = "" | |
SCHEME = "" | |
CONFIG_NAME = "" | |
IPA_NAME = "" | |
HTTP_URL = "" | |
SFTP_SERVER = "" | |
SFTP_PATH = "" | |
SFTP_PORT = 22 | |
NOTIF_EMAIL = "lohas@lexrus.mailgun.org" | |
EMAIL_DOMAIN = "lexrus.mailgun.org" | |
# Add a Password item to your Keychain and set its comment to "mailgun api key" so that we can find it | |
MAILGUN_KEY = commands.getoutput('security find-generic-password -j "mailgun api key" -gw') | |
def clean(): | |
call_shell(["xcodebuild", "clean"]) | |
call_shell(["rm", "-rf", "Build"]) | |
print "All build files cleared." | |
def xcodebuild(): | |
print "Start building target %s ..." % APP_NAME | |
cmd = ["xcodebuild", "-workspace", WORKSPACE, "-scheme", SCHEME, "-configuration", CONFIG_NAME, "clean", "build"] | |
call(cmd) | |
def package(): | |
print "Start packaging ..." | |
call_shell(["rm", "-rf", "Payload"]) | |
os.mkdir("Payload") | |
call_shell(["mv", "Products/%s-iphoneos/%s.app" % (CONFIG_NAME, APP_NAME), "Payload"]) | |
call_shell(["zip", "-r", IPA_NAME, "Payload"]) | |
call_shell(["mv", IPA_NAME, "pkg"]) | |
def prepare(): | |
print "Start preparing files for deploy ..." | |
appName = "%s.app" % (APP_NAME) | |
call_shell(["rm", "-rf", "pkg"]) | |
os.mkdir("pkg") | |
call_shell(["cp", "Products/%s-iphoneos/%s/Info.plist" % (CONFIG_NAME, appName), "Info.plist"]) | |
pl = plist_to_dictionary("Info.plist") | |
global IPA_NAME | |
IPA_NAME = "%s_%s_%s.ipa" % (APP_NAME, pl["CFBundleShortVersionString"], pl["CFBundleVersion"]) | |
call_shell(["cp", "Products/%s-iphoneos/%s/%s" % (CONFIG_NAME, appName, icon_file(pl)), "pkg/%s" % icon_file(pl)]) | |
content = manifest(pl) | |
f = open("pkg/%s.plist" % IPA_NAME, "w") | |
f.write(content.encode('utf8')) | |
f.close() | |
content = index_html(pl) | |
f = open("pkg/index.html", "w") | |
f.write(content.encode('utf8')) | |
f.close() | |
qr = qrcode.QRCode( | |
version = 1, | |
error_correction = qrcode.constants.ERROR_CORRECT_L, | |
box_size = 7, | |
border = 2, | |
) | |
qr.add_data(HTTP_URL) | |
qr.make(fit=True) | |
qrImg = qr.make_image() | |
qrImg.save("pkg/qr.png", "PNG") | |
def sftp(): | |
print "" | |
print "Start uploading files to %s:%s ..." % (SFTP_SERVER, str(SFTP_PORT)) | |
(width, height) = terminal_size() | |
print "-" * width | |
cmd = ["scp", "-P", "%s" % str(SFTP_PORT), "-r", "pkg/.", "%s:%s" % (SFTP_SERVER, SFTP_PATH)] | |
call(cmd) | |
print "-" * width | |
print "Finished uploading files." | |
print "Download app at %s" % HTTP_URL | |
def send_notification(): | |
print "Start sending notification emails ..." | |
f = open("pkg/index.html", "r") | |
content = f.read().decode('utf8') | |
f.close() | |
pl = plist_to_dictionary("Info.plist") | |
appFullName = "%s %s (%s)" % (pl["CFBundleName"], pl["CFBundleShortVersionString"], pl["CFBundleVersion"]) | |
ret = send_mailgun(appFullName, content) | |
ret = json.loads(ret) | |
print ret["message"] | |
def manifest(pl): | |
template = ''' | |
<?xml version="1.0" encoding="UTF-8"?> | |
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
<plist version="1.0"> | |
<dict> | |
<key>items</key> | |
<array> | |
<dict> | |
<key>assets</key> | |
<array> | |
<dict> | |
<key>kind</key> | |
<string>software-package</string> | |
<key>url</key> | |
<string>%s</string> | |
</dict> | |
<dict> | |
<key>kind</key> | |
<string>full-size-image</string> | |
<key>needs-shine</key> | |
<false/> | |
<key>url</key> | |
<string>%s</string> | |
</dict> | |
<dict> | |
<key>kind</key> | |
<string>display-image</string> | |
<key>needs-shine</key> | |
<false/> | |
<key>url</key> | |
<string>%s</string> | |
</dict> | |
</array> | |
<key>metadata</key> | |
<dict> | |
<key>bundle-identifier</key> | |
<string>%s</string> | |
<key>bundle-version</key> | |
<string>%s</string> | |
<key>kind</key> | |
<string>software</string> | |
<key>title</key> | |
<string>%s</string> | |
</dict> | |
</dict> | |
</array> | |
</dict> | |
</plist> | |
''' | |
appUrl = HTTP_URL + "/" + IPA_NAME | |
iconUrl = HTTP_URL + "/" + icon_file(pl) | |
return template % (appUrl, iconUrl, iconUrl, pl["CFBundleIdentifier"], pl["CFBundleVersion"], pl["CFBundleName"]) | |
def index_html(pl): | |
template = u''' | |
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> | |
<html xmlns="http://www.w3.org/1999/xhtml"> | |
<head> | |
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"> | |
<title>%s - Beta</title> | |
<style type="text/css"> | |
body{text-align:center;} | |
#container{width:300px;margin:0 auto;} | |
h1{margin:0;padding:0;font-size:14px;} | |
p{font-size:13px;} | |
.install_button{line-height:44px;margin:.5em auto;background:#89c4dc;background-image:-webkit-linear-gradient(top,rgb(126,203,26),rgb(92,149,19));background-origin:padding-box;background-repeat:repeat;-webkit-box-shadow:rgba(0,0,0,0.36) 0px 1px 3px 0px;-webkit-font-smoothing:antialiased;-webkit-user-select:none;background-attachment:scroll;background-clip:border-box;background-color:rgba(0,0,0,0);border-color:#75bc18;border-bottom-left-radius:16px;border-bottom-right-radius:16px;border-bottom-style:none;border-bottom-width:0px;border-left-style:none;border-left-width:0px;border-right-style:none;border-right-width:0px;border-top-left-radius:16px;border-top-right-radius:16px;border-top-style:none;border-top-width:0px;box-shadow:rgba(0,0,0,0.359375) 0px 1px 3px 0px;color:rgb(0,140,221);cursor:pointer;display:inline-block;font-family:proxima-nova,arial,sans-serif;font-size:20px;margin:10px 0;padding:1px;position:relative;text-align:center;text-decoration:none;-webkit-box-shadow:0 1px 3px rgba(0,0,0,0.36);} | |
.install_button a{font-weight:bold;font-size:24px;-webkit-box-shadow:rgba(255,255,255,0.25) 0px 1px 0px 0px inset;-webkit-font-smoothing:antialiased;-webkit-user-select:none;background-attachment:scroll;background-clip:border-box;background-color:rgba(0,0,0,0);background-image:-webkit-linear-gradient(top,rgb(195,250,123),rgb(134,216,27) 85%%,rgb(180,231,114));background-origin:padding-box;background-repeat:repeat;border-bottom-color:rgb(255,255,255);border-bottom-left-radius:15px;border-bottom-right-radius:15px;border-bottom-style:none;border-bottom-width:0px;border-left-color:rgb(255,255,255);border-left-style:none;border-left-width:0px;border-right-color:rgb(255,255,255);border-right-style:none;border-right-width:0px;border-top-color:rgb(255,255,255);border-top-left-radius:15px;border-top-right-radius:15px;border-top-style:none;border-top-width:0px;box-shadow:rgba(255,255,255,0.246094) 0px 1px 0px 0px inset;color:#fff;cursor:pointer;display:block;font-family:proxima-nova,arial,sans-serif;font-size:14px;font-weight:bold;height:31px;line-height:31px;margin:0;padding:0;text-align:center;text-decoration:none;text-shadow:rgba(0,0,0,0.527344) 0px 1px 1px;width:298px;} | |
.last_updated{font-size:x-small;text-align:center;font-weight:bolder;} | |
.icon{border-radius:10px;box-shadow:1px 2px 3px #ccc;} | |
.release_notes{border:1px solid #999;padding:20px;border-radius:6px;overflow:hidden;} | |
.release_notes:before{font-size:10px;content:"更新内容";background:#999;margin:-20px;float:left;padding:2px 5px;border-radius:3px 0 6px 0;color:#fff;} | |
</style> | |
</head> | |
<body> | |
<div id="container"> | |
<p><img class="icon" src='%s' height='57' width='57'/></p> | |
<h1>%s</h1> | |
<div class="install_button"><a href="itms-services://?action=download-manifest&url=%s/%s.plist">在 iOS 设备上点击安装</a></div> | |
<p class="release_notes">%s</p> | |
<p><a href="%s">%s</a></p> | |
<p><img src="%s/qr.png"/></p> | |
<small>%s</small> | |
</body> | |
</html> | |
''' | |
reason = get_editor_input() | |
icon = icon_file(pl) | |
iconUrl = "%s/%s" % (HTTP_URL, icon) | |
appFullName = "%s %s (%s)" % (pl["CFBundleName"], pl["CFBundleShortVersionString"], pl["CFBundleVersion"]) | |
SHORT_URL = goo_gl(HTTP_URL) | |
timeStr = datetime.now().strftime("%Y/%m/%d %H:%M:%S") | |
print "appFullName" + appFullName | |
return template % (appFullName, iconUrl, appFullName, HTTP_URL, IPA_NAME, reason, SHORT_URL, SHORT_URL, HTTP_URL, timeStr) | |
def get_editor_input(): | |
cmd = os.environ['EDITOR'] | |
if not cmd: | |
cmd = "/usr/bin/vim" | |
tmpFile = "/tmp/%s.input" % IPA_NAME | |
f = open(tmpFile, "w") | |
f.write("# What's NEW in this build?\n\n\n") | |
f.close() | |
call([cmd, tmpFile]) | |
f = open(tmpFile, "r") | |
contents = f.read().decode('utf8').split("\n") | |
f.close() | |
result = "" | |
for line in contents: | |
line = line.strip() | |
if line.find("#") == 0 or len(line) == 0: | |
continue | |
result += line + "<br/>" | |
if len(result) == 0: | |
result = "-" | |
return result | |
def icon_file(pl): | |
icon = "" | |
if "CFBundleIconFiles" in pl: | |
icon = pl["CFBundleIconFiles"][0] | |
elif "CFBundleIcons" in pl: | |
icon = pl["CFBundleIcons"]["CFBundlePrimaryIcon"]["CFBundleIconFiles"][0] | |
return string.replace(icon, "@2x", "") | |
def plist_to_dictionary(filename): | |
"Pipe the binary plist through plutil and parse the JSON output" | |
with open(filename, "rb") as f: | |
content = f.read() | |
args = ["plutil", "-convert", "json", "-o", "-", "--", "-"] | |
p = Popen(args, stdin=PIPE, stdout=PIPE) | |
p.stdin.write(content) | |
out, err = p.communicate() | |
return json.loads(out) | |
def call_shell(cmd): | |
try: | |
p = Popen(cmd, stdin=None, stdout=PIPE) | |
out, err = p.communicate() | |
retcode = p.returncode | |
if retcode < 0: | |
print >> sys.stderr, "Child was terminated by signal", -retcode | |
except OSError, e: | |
print >> sys.stderr, "Execution failed:", e | |
def terminal_size(): | |
import os | |
env = os.environ | |
def ioctl_GWINSZ(fd): | |
try: | |
import fcntl, termios, struct, os | |
cr = struct.unpack('hh', fcntl.ioctl(fd, termios.TIOCGWINSZ, | |
'1234')) | |
except: | |
return None | |
return cr | |
cr = ioctl_GWINSZ(0) or ioctl_GWINSZ(1) or ioctl_GWINSZ(2) | |
if not cr: | |
try: | |
fd = os.open(os.ctermid(), os.O_RDONLY) | |
cr = ioctl_GWINSZ(fd) | |
os.close(fd) | |
except: | |
pass | |
if not cr: | |
try: | |
cr = (env['LINES'], env['COLUMNS']) | |
except: | |
cr = (25, 80) | |
return int(cr[1]), int(cr[0]) | |
def send_mailgun(appName, message): | |
api_url = "https://api.mailgun.net/v2/%s/messages" % EMAIL_DOMAIN | |
data = {"from": "Lex <postmaster@%s>" % EMAIL_DOMAIN, | |
"to": NOTIF_EMAIL, | |
"subject": "%s is ready!" % appName, | |
"text": "%s is ready!" % appName, | |
"html": message.encode('utf-8')} | |
request = urllib2.Request(api_url) | |
print "Sending notification to:" + NOTIF_EMAIL | |
request.add_header('Authorization', 'Basic ' + b64encode("api:%s" % MAILGUN_KEY)) | |
request.add_data(urlencode(data)) | |
try: | |
r = urllib2.urlopen(request) | |
return r.read() | |
except Exception, e: | |
print e | |
def goo_gl(url): | |
API="https://www.googleapis.com/urlshortener/v1/url" | |
if re.match('http://goo\.gl/.+',url):return J(U(API+'?shortUrl=%s'%url).read())['longUrl'] | |
else:return J(U(R(API,'{"longUrl":"%s"}'%url,{'Content-Type':'application/json'})).read())['id'] | |
############################################################ | |
# main | |
############################################################ | |
def usage(): | |
print "This tool is used to make it easy to build and distribute enterprise iOS app." | |
print "It also send out notification via eMails (via mailgun)" | |
print "" | |
print "# Usage: build.py [-h] -workspace workspace_name -scheme scheme_name -t target_name -s sftp_server -sp sftp_port -p sftp_path -u http_url [command]" | |
print "# command = clean|upload|build|notif" | |
def main(argv): | |
try: | |
opts, args = getopt.getopt(argv, "hw:e:t:s:o:p:u:n:m:d:c:", | |
["help", "workspace=", "scheme=", "target=", "server=", "server_port=", "path=", "http_url=", "notif_email=", | |
"mailgun_key=", "email_domain=", "configuration="]) | |
except getopt.GetoptError: | |
usage() | |
sys.exit(2) | |
for opt, arg in opts: | |
if opt in ("-h", "--help"): | |
usage() | |
sys.exit() | |
if opt in ("-c", "--configuration"): | |
global CONFIG_NAME | |
CONFIG_NAME = arg | |
if opt in ("-w", "--workspace"): | |
global WORKSPACE | |
WORKSPACE = arg | |
if opt in ("-e", "--scheme"): | |
global SCHEME | |
SCHEME = arg | |
if opt in ("-t", "--target"): | |
global APP_NAME | |
APP_NAME = arg | |
if opt in ("-s", "--server"): | |
global SFTP_SERVER | |
SFTP_SERVER = arg | |
if opt in ("-o", "--server_port"): | |
global SFTP_PORT | |
SFTP_PORT = arg | |
if opt in ("-p", "--path"): | |
global SFTP_PATH | |
SFTP_PATH = arg | |
if opt in ("-u", "--http_url"): | |
global HTTP_URL | |
HTTP_URL = arg | |
if opt in ("-d", "--email_domain"): | |
global EMAIL_DOMAIN | |
EMAIL_DOMAIN = arg | |
if opt in ("-n", "--notif_email"): | |
global NOTIF_EMAIL | |
NOTIF_EMAIL = arg | |
if opt in ("-m", "--mailgun_key"): | |
global MAILGUN_KEY | |
MAILGUN_KEY = arg | |
if argv and argv[-1] == "clean": | |
clean() | |
sys.exit(0) | |
if argv and argv[-1] == "notif": | |
os.chdir("Build") | |
send_notification() | |
sys.exit(0) | |
if not APP_NAME or not SFTP_SERVER or not SFTP_PATH or not HTTP_URL: | |
usage() | |
sys.exit(2) | |
if argv and argv[-1] == "build": | |
clean() | |
xcodebuild() | |
os.chdir("Build") | |
prepare() | |
package() | |
sys.exit(0) | |
if argv and argv[-1] == "upload": | |
os.chdir("Build") | |
sftp() | |
sys.exit(0) | |
if __name__ == "__main__": | |
main(sys.argv[1:]) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment