搭建ipa内网分发(OTA)

内测包的分发,前前后后也使用了很多方案,之前使用fir、pgyer,后来看到了开源的zealot,可以部署在内部服务器,用着挺好。因为是在外网服务器上,所以随着ipa包的变大及公司外网限速,每次安装内测包要5分钟+...

因资源有限,就自己用打包机器(Mac mini)完成了整套配置

主要包括:

  • 自动化打包产出ipa(基于Jenkins + fastlane + 自建zealot 已经稳定使用很久了 )
  • TLS证书 (支持ip访问的自签名证书,生成脚本下面有给出)
  • 支持https的服务器(选择miniserver 轻量易用)
  • 管理脚本 (基于fastlane 、 ruby,下面有给出)
itms-services://?action=download-manifest&url=https://xxxxxxx.plist

分发ipa,肯定离不开这个协议,plist文件的内容也是固定的格式,这里需要注意的是,这个plist文件的url必须是https的,至于plist内部的ipa文件的url,其实http也是没有问题的。

  • plist_URL (https) + ipa_URL (https) : 可以安装,但必须要信任签名CA证书,否则安装失败
  • plist_URL (https) + ipa_URL (http) : 可以安装,无需信任自签名CA证书 (我选用了这个组合,比较省事)注意:在iOS12这个组合无法安装,我之前测试用的系统版本比较高,如果想支持<=iOS12的测试机,建议还是使用上面双https的组合
自签名证书(ip)

这里卡了很久,生成了很多证书都有问题,附上最终的脚本

#!/bin/bash

#创建根密钥
openssl ecparam -out ROOT_CA_PRIVATEKEY.key -name secp384r1 -genkey

#创建根证书CSR
openssl req -new -sha256 -key ROOT_CA_PRIVATEKEY.key -out ROOT_CA_CSR.csr -subj "/C=CN/ST=SH/L=PD/OU=XYZ_iOS/O=XYZ_iOS/CN=XYZ_IOS_CA"

#创建一个 CA 根证书的配置文件 
ROOT_CA_Path="./ROOT_CA.cnf"
(
cat << EOF
basicConstraints=critical,CA:TRUE
nsComment = "This Root certificate was generated by dadadongL"
keyUsage=critical, keyCertSign
subjectKeyIdentifier=hash
EOF
) > $ROOT_CA_Path

# 创建自签名CA
openssl x509 -req -sha256 -days 3650 -extfile $ROOT_CA_Path -in ROOT_CA_CSR.csr -signkey ROOT_CA_PRIVATEKEY.key -out ROOT_CA_CERT.crt


# ⚠️⚠️⚠️自签名的ip 记得改成自己的⚠️⚠️⚠️
ip_server="172.18.41.180"

# 创建证书的密钥 和 CSR 文件
openssl req -newkey rsa:2048 -nodes -subj "/C=CN/ST=SH/O=XYZ_iOS/OU=XYZ_iOS/CN=$ip_server" -keyout server-key.key -out server-csr.csr

# 2.1 创建一个配置文件  很重要!! 不然浏览器任然会提示不安全 NET::ERR_CERT_COMMON_NAME_INVALID
# 需要把签发的域名 或者IP地址 填到 [alt_names] 里面
ssl_cnf_path="./ssl.cnf"
(
cat << EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names

[alt_names]
IP.1 = $ip_server
EOF
) > $ssl_cnf_path

# 签发 证书有效期最长为13个月 (398 天), 不然浏览器会显示不安全
openssl x509 -req -CA ROOT_CA_CERT.crt -CAkey ROOT_CA_PRIVATEKEY.key -CAcreateserial -days 365 -sha256 -extfile $ssl_cnf_path -in server-csr.csr -out server-crt.crt

# 校验
openssl verify -CAfile ROOT_CA_CERT.crt server-crt.crt

ROOT_CA_CERT.crt 自签名CA证书 (设备安装&信任了这个根证书,其签发的证书都不会再被浏览器警告)
./server-crt.crt 待使用的证书
./server-key.key 待使用的证书私钥

服务器搭建

这个有很多方案都可以,Mac自带的Apache或者Nginx...
我选用了这个 https://github.com/svenstaro/miniserve,直接映射磁盘指定目录,并且轻量级。
因为同时需要两个https 、http,所以起了两个服务 (记得自行配置开机自启动)

## 端口号 证书路径 自行修改
#  nohup commend &  这种格式是为了后台运行

cd ~/
# 启动https服务
nohup miniserve -p 8000 -z -v -t "iOS开发部FTP" -U -u --tls-cert ~/Desktop/online_auto_run/server-crt.crt --tls-key ~/Desktop/online_auto_run/server-key.key  ~/Documents/ftpRoot &

# 启动http服务
nohup miniserve -p 8001 -v -t "iOS开发部FTP" -U -u  ~/Documents/ftpRoot &

这样就可以通过http://本机ip:8001/https://本机:8000/进行访问了 (也可以当做内网的一个FTP用😆)

管理脚本

我是基于fastlane 、 ruby实现的,代码都比较简单,最终会返回的html页面地址,内网打开即可
主要步骤:

  1. 拷贝ipa文件到服务映射的磁盘目录
  2. 生成plist文件
  3. 生成下载二维码图片
  4. 生成下载页面html
#  " --- 尝试 配置本次打包的 内网下载配置 --- "
    #入参 pj_scheme: app scheme 用来生成路径(不用中文的title是因为 路径是url的一部分)
    #入参 pj_env: ”1“/”0“ 用来生成路径
    #入参 pj_ver: 主版本号 用来生成路径的一部分 
    #入参 pj_ipa_path: 本次打包的ipa文件路径 (绝对路径)
    #入参 pj_main_bundleID: 主包名即可 用来生成plist文件
    #入参 pj_title: app名称 用来生成plist文件
    #返回值: {pageUrl:  xxxxxxxx.html }
    lane :try_moveIPA_to_serverPath do |variable|
        ipa_server_rootpath = File.expand_path("~/Documents/ftpRoot/itms-services")
        if Dir.exist?(ipa_server_rootpath) == false 
            puts "未检测到 ipa_server 目录,跳过内网下载处理~"
            next {}
        end
        puts "检测到 ipa_server 目录,自动配置当前ipa支持内网下载 ~~~~"

        pj_scheme = variable[:pj_scheme]
        pj_env = variable[:pj_env] == "1" ? "_product_" : "_test_"      
          
        env_Dir_path = File.join(ipa_server_rootpath, pj_scheme, pj_env) 
        if Dir.exist?(env_Dir_path) 
            puts "自动删除 3 天前的包...."
            Dir.entries(env_Dir_path).each do |item|
                # 去除.开头的文件
                next if File.basename(item).start_with?(".")
                # 
                item_abs_path = File.join(env_Dir_path, item)
                sh("rm", "-R", item_abs_path) if (Time.new - File.mtime(item_abs_path) > (3 * 24 * 3600))
            end
        else
            # 创建文件夹
            sh("mkdir", "-p", env_Dir_path)
        end

        pj_ver = variable[:pj_ver]

        new_pj_ver = "#{pj_ver}-" + Time.new.strftime('%Y-%m-%d_%H-%M')
        current_dir_path = File.join(env_Dir_path, new_pj_ver)
        sh("rm", "-R", current_dir_path) if Dir.exist?(current_dir_path) 
        sh("mkdir", "-p", current_dir_path)


        # 为了在安装tsl证书前能访问html, ci服务器上跑了两个服务
        http_server_domain = "http://本机IP:8001/itms-services"
        https_server_domain = "https://本机IP:8000/itms-services"
        root_CA_URL = "http://本机IP:8001/CA/ROOT_CA_CERT.crt"

        # copy过来ipa
        puts "copy ipa 文件...."
        sh("cp", "-f", File.expand_path(variable[:pj_ipa_path]), "#{current_dir_path}/ipa.ipa")
        ipa_URL = File.join(http_server_domain, pj_scheme, pj_env, new_pj_ver, "ipa.ipa")

        # 生成plist
        puts "生成mainfest.plist文件...."
        plist_base64 = "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCFET0NUWVBFIHBsaXN0IFBVQkxJQyAiLS8vQXBwbGUvL0RURCBQTElTVCAxLjAvL0VOIiAiaHR0cDovL3d3dy5hcHBsZS5jb20vRFREcy9Qcm9wZXJ0eUxpc3QtMS4wLmR0ZCI+CjxwbGlzdCB2ZXJzaW9uPSIxLjAiPgo8ZGljdD4KCTxrZXk+aXRlbXM8L2tleT4KCTxhcnJheT4KCQk8ZGljdD4KCQkJPGtleT5hc3NldHM8L2tleT4KCQkJPGFycmF5PgoJCQkJPGRpY3Q+CgkJCQkJPGtleT5raW5kPC9rZXk+CgkJCQkJPHN0cmluZz5zb2Z0d2FyZS1wYWNrYWdlPC9zdHJpbmc+CgkJCQkJPGtleT51cmw8L2tleT4KCQkJCQk8c3RyaW5nPl9hcHBfaXBhVVJMXzwvc3RyaW5nPgoJCQkJPC9kaWN0PgoJCQk8L2FycmF5PgoJCQk8a2V5Pm1ldGFkYXRhPC9rZXk+CgkJCTxkaWN0PgoJCQkJPGtleT5idW5kbGUtaWRlbnRpZmllcjwva2V5PgoJCQkJPHN0cmluZz5fYXBwX2J1bmRsZWlkXzwvc3RyaW5nPgoJCQkJPGtleT5idW5kbGUtdmVyc2lvbjwva2V5PgoJCQkJPHN0cmluZz5fYXBwX3Zlcl88L3N0cmluZz4KCQkJCTxrZXk+a2luZDwva2V5PgoJCQkJPHN0cmluZz5zb2Z0d2FyZTwvc3RyaW5nPgoJCQkJPGtleT50aXRsZTwva2V5PgoJCQkJPHN0cmluZz5fYXBwX3RpdGxlXzwvc3RyaW5nPgoJCQk8L2RpY3Q+CgkJPC9kaWN0PgoJPC9hcnJheT4KPC9kaWN0Pgo8L3BsaXN0Pgo="
        plist_Content = Base64.decode64(plist_base64)
        # 有几个占位符需要替换掉 _app_title_ 、_app_ver_ 、 _app_bundleid_ 、 _app_ipaURL_ (其实只有_app_ipaURL_是核心)
        plist_Content = plist_Content.gsub("_app_ipaURL_", ipa_URL)
        plist_Content = plist_Content.gsub("_app_ver_", pj_ver)
        plist_Content = plist_Content.gsub("_app_bundleid_", variable[:pj_main_bundleID])
        plist_Content = plist_Content.gsub("_app_title_", variable[:pj_title])
        # 写入文件
        File.open("#{current_dir_path}/manifest.plist", "w+:utf-8") do |lines|  #读写模式。如果文件存在,则重写已存在的文件。如果文件不存在,则创建一个新文件用于读写
            lines.write(plist_Content) 
        end
        plist_URL = File.join(https_server_domain, pj_scheme, pj_env, new_pj_ver, "manifest.plist")

        install_URL = "itms-services://?action=download-manifest&url=#{plist_URL}"
        

        # 创建安装二维码图片文件
        qr = RQRCode::QRCode.new(install_URL, :level=>:h)
        png = qr.as_png(
            resize_gte_to: false,
            resize_exactly_to: false,
            fill: 'white',
            color: 'black',
            size: 180,
            border_modules: 0,
            module_px_size: 0,
            file: "#{current_dir_path}/QRImg.png" # path to write
        )
        qR_Img_URL = File.join(http_server_domain, pj_scheme, pj_env, new_pj_ver, "QRImg.png")


        # 生成html
        html_base64 = "PCFET0NUWVBFIGh0bWw+CjxodG1sPgogICAgPGhlYWQ+CiAgICAgIDxtZXRhIG5hbWU9InZpZXdwb3J0ImNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCwgaW5pdGlhbC1zY2FsZT0xLjAsIG1pbmltdW0tc2NhbGU9MC41LCBtYXhpbXVtLXNjYWxlPTIuMCwgdXNlci1zY2FsYWJsZT15ZXMiLz4KICAgICAgPG1ldGEgaHR0cC1lcXVpdj0iQ29udGVudC1UeXBlIiBjb250ZW50PSJ0ZXh0L2h0bWw7IGNoYXJzZXQ9VVRGLTgiPgogICAgPC9oZWFkPgogICAgPGJvZHk+CiAgICAgICAgPGg0PuaJi+acuummluasoeS4i+i9veivt+WFiCLngrnlh7vlronoo4VTU0zor4HkuaYi77yM5bm25qC55o2u5o+Q56S65a6J6KOFL+S/oeS7u+ivgeS5pjwvaDQ+CiAgICAgICAgPGEgdGl0bGU9ImlQaG9uZSIgaHJlZj0iX1Jvb3RfQ0FfVVJMXyI+6aaW5qyh6ZyA54K55q2k5a6J6KOFU1NM6K+B5LmmPC9hPgogICAgICAgIOWuieijhea1geeoi+WQjCLmipPljIXor4HkuaYi77yM5a6J6KOF5ZCO6ZyA6KaB5omL5Yqo5byA5ZCv5Y+XIFNTTCDkv6Hku7vvvIjliY3lvoDigJzorr7nva7igJ0+4oCc6YCa55So4oCdPuKAnOWFs+S6juacrOacuuKAnT7igJzor4Hkuabkv6Hku7vorr7nva7igJ3vvIkKICAgICAgICA8aHI+CiAgICAgICAgPGg0PuWmguaenOS9oOaYr+aJi+acuuaJk+W8gOeahOacrOmhtemdojwvaDQ+CiAgICAgICAgPGEgaHJlZj0iX2l0bXMtc2VydmljZXNfdXJsXyIgY2xhc3M9ImFwcF9saW5rIj7ngrnlh7vlronoo4U8L2E+CiAgICAgICAgPGhyPgogICAgICAgIDxoND7lpoLmnpzkvaDmmK/nlLXohJHmiZPlvIDnmoTmnKzpobXpnaLvvIzor7fnlKjmiYvmnLrmiavov5nkuKrkuoznu7TnoIHlronoo4U8L2g0PgogICAgICAgIDxpbWcgc3JjPSJfcXJfaW1hZ2VfdXJsXyI+CiAgICA8L2JvZHk+CjwvaHRtbD4="
        html_Content = Base64.decode64(html_base64).force_encoding("UTF-8")
        # 有几个占位符需要替换掉 _itms-services_url_ 、 _qr_image_url_ 、 _Root_CA_URL_
        html_Content = html_Content.gsub("_itms-services_url_", install_URL)
        html_Content = html_Content.gsub("_qr_image_url_", qR_Img_URL)
        html_Content = html_Content.gsub("_Root_CA_URL_", root_CA_URL)
        # 写入文件
        File.open("#{current_dir_path}/index.html", "w+:utf-8") do |lines|  #读写模式。如果文件存在,则重写已存在的文件。如果文件不存在,则创建一个新文件用于读写
            lines.write(html_Content) 
        end

        # 这个html 用http访问
        {"pageUrl" => File.join(http_server_domain, pj_scheme, pj_env, new_pj_ver, "index.html")}        
    end
通知

执行完成后,只需要改下钉钉通知/邮件通知,这个比较简单就不赘述了。
我们的差不多就这样子:


钉钉群通知
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 193,968评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,682评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,254评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,074评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,964评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,055评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,484评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,170评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,433评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,512评论 2 308
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,296评论 1 325
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,184评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,545评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,150评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,437评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,630评论 2 335

推荐阅读更多精彩内容