AirBuild
工程

第一代 App Store,用 AI 编程重新想象。

2008 年,iPhone 3G 与第一代 App Store 让许多人第一次接触到移动 App。我们开发了 AirBuild3G,让这部手机又有了新用处。在 iPhone 3G 或 3GS 上,你可以描述一个新的 App,借助编程模型修改它的源代码,再在同一部手机上编译并运行。模型在你选择的服务上运行,项目文件和开发工具则留在手机上。

AirBuild3G 本身是在 Mac 上用 Xcode 27 构建的。你用它创建的 App 则使用安装在手机上的另一套编译器。要让两者都能运行,我们需要处理四方面的问题:设备准备、链接、HTTPS,以及手机自身的工具链。下面几节会顺着这些工作,一直讲到你的第一个 App。

准备原始硬件

小巧的显示屏和实体主屏幕按钮,让人一眼就能认出 iPhone 3G。再次为它开发,就要尊重它原有的比例和限制,包括那些让当年 App 用起来如此熟悉的控件。AirBuild3G 本身使用 iOS 4 提供的 UIKit 控件,包括分组设置表格和导航栏操作。

AirBuild3G 可在 iPhone 3G 和 3GS 上运行。3G 需要 armv6 二进制文件,3GS 使用 armv7。每部手机能安装哪个环境包,由其固件决定。我们用作参考的 armv6 设备是一部运行 iOS 4.2.1 并装有自定义固件的 iPhone 3G。

  1. 准备恢复 / 激活
  2. 连接SSH + 网络
  3. 安装App + 环境包
  4. 环境设置 → 环境
设置分四步:准备手机,连接手机,安装 AirBuild3G 和环境包,然后在“设置”→“环境”中轻点“安装”。

先准备一部能正常进入主屏幕,并能接受 Mac 通过 SSH 连接的手机。它需要可用的网络连接和软件包管理器,以接收并安装 Debian 软件包。要发送哪些软件包取决于硬件型号和固件版本,发送到哪里则取决于网络地址。你还需要一根可靠的 USB 线缆和电源。

有些老款手机还需要额外步骤才能激活并取得系统访问权限。对于支持的硬件,请使用 Legacy iOS Kit,并按照其中针对手机型号和固件的说明操作。开始之前,请先备份手机。AirBuild3G 本身不会激活手机。

AirBuild3G 的 TLS 支持还需要 Substrate。在 3G 上,构建产物必须包含 armv6:无论版本是什么,只有 armv7 的软件包都无法运行。用 Legacy iOS Kit 准备好的手机可能已经装有兼容的构建版本。如果 Substrate 可以正常运行,就保留现状。

“设置”→“环境”也遵循同一原则。它会添加开发工具,并保留可以正常工作的软件包管理器、SSH 服务和其他基础组件。整体升级这些组件,可能会装入旧系统无法运行的二进制文件。

使用 Xcode 27 构建

Xcode 27 的编译器仍能生成 armv6 和 armv7 代码,也接受 iOS 4.0 部署目标。Xcode 本身只附带 iOS 27 SDK,因此我们使用旧版 SDK 的头文件进行编译。在 make 命令行中通过 LEGACY_SDK 传入 SDK 路径,或者将其导出。构建环境包的脚本也会读取这个值,只在 Makefile 内设置的值无法传给这些脚本。

链接器的要求更严格。它不接受 armv6 作为链接目标,拒绝低于 iOS 4.3 的任何部署目标,还会将旧版 SDK 的库报告为针对未知平台构建。对于这些库,我们改为链接生成的存根,旧版 SDK 则继续用于头文件。

  1. 已编译clang
    架构
    armv6
    最低 iOS
    4.0
    代码
    e92d40f0 …
  2. 已链接重新标记,再用 ld 链接
    架构
    armv6armv7
    最低 iOS
    4.05.0
    代码
    e92d40f0 …
  3. 已交付标记改回,再修补
    架构
    armv7armv6
    最低 iOS
    5.04.0
    代码
    e92d40f0 …
    make verify
Clang 生成 armv6 指令。链接前后,构建过程只改写元数据,随后检查结果。

构建过程会为 iOS 4.0 编译两个切片,以 iOS 5.0 为目标完成链接,再将它们的最低版本修补回 4.0。对于 armv6,它还会把每个目标文件重新标记为 armv7,让链接器接受,再把链接后的切片重新标记回 armv6。重新标记改变的是元数据,不是指令。编译出的代码和链接器添加的符号存根,本来就必须能在 armv6 上运行。

tools/relabel_arch.py
MH_MAGIC = 0xFEEDFACE
SUBTYPES = {"armv6": 6, "armv7": 9}

def main():
    …
    path, want = sys.argv[1], SUBTYPES[sys.argv[2]]

    with open(path, "rb") as f:
        buf = bytearray(f.read())

    if struct.unpack_from("<I", buf, 0)[0] != MH_MAGIC:
        …

    struct.pack_into("<i", buf, 8, want)
    with open(path, "wb") as f:
        f.write(buf)
  1. Mach-O 中各架构的 cpusubtype:6 代表 armv6,9 代表 armv7。
  2. 全部重新标记只涉及偏移量 8 处的四个字节,也就是头部的 cpusubtype 字段。指令原样通过。
构建过程会在链接前对每个 armv6 目标文件运行这个脚本,并在链接后对切片再运行一次。

构建过程保留 -no_pie,因为 iOS 在 4.3 之前没有 ASLR。它还会关闭 Objective-C 分类合并,否则合并后的方法会丢失 Thumb 位,导致 AirBuild3G 在 3GS 上崩溃。make verify 会检查架构、加载命令、armv6 指令和 armv7 方法的 Thumb 位。仅仅链接成功,并不能证明这些都正确。

运行 make 构建 AirBuild3G 及其特权辅助程序。make payload 会将所有锁定版本的软件包下载到本地缓存并逐一验证,make package 则利用缓存离线构建 AirBuild3G 软件包和两个环境包。打包也需要 LEGACY_SDK,因为每个环境包都要为手机附带一份 SDK 归档。

make install-device DEVICE=root@<ip> 会安装 AirBuild3G 和 iOS 4 环境包,刷新主屏幕,然后重新启动 App。对于运行 iOS 5 或更高版本的 3GS,请改为向 tools/deploy_device.sh 传入 ios6 作为第三个参数。App 以 mobile 用户身份运行,旁边有一个小型辅助程序负责执行所有需要 root 权限的操作。环境包此时已经在手机上,“设置”→“环境”会安装其中包含的开发工具。

连接模型服务

在“设置”中,你需要在“端点”下输入三个值:基础 URL、API 密钥和模型。请求会从手机直接发往该端点。中间没有我们的服务器,也无需注册账户或设备。你的模型服务负责运行模型、收取用量费用,并告诉你该填写什么。如果手机是我们赠送的,它会改为连接 air.build。

原版 iOS 4 只能通过 SecureTransport 使用 TLS 1.0,而当前的端点可能要求 TLS 1.2。我们通过 TLSFix 添加 TLS 1.2 支持,它是面向 CFNetwork 的 Substrate tweak。

手机仍要验证服务器身份。它的旧信任存储未必能构建当前端点使用的证书链,添加 TLS 1.2 也不会更新这个存储。连接失败时,请把加密和证书信任当作两个独立问题排查。

AirBuild3G 默认会验证证书。如果请求失败,请先检查基础 URL、API 密钥、模型和证书链。“信任任何证书”是一项明确的例外设置。启用后,服务器身份检查会被移除,任何能够冒充该端点的人都可能取得你的 API 密钥。除非端点位于你自己的网络中,否则请保持关闭。AirBuild3G 会在每次进行信任评估时读取这项设置,因此关闭后会从下一次请求起生效。

src/ABLTrustAll.m
static OSStatus ABLSecTrustEvaluate(SecTrustRef trust, SecTrustResultType *result) {
	…
	OSStatus status = ABLOriginalSecTrustEvaluate(trust, result);
	if (!ABLTrustAnyCertificate()) {
		return status;
	}
	if (result != NULL) {
		*result = kSecTrustResultUnspecified;
	}
	return errSecSuccess;
}
  1. 手机会先进行自己的评估。
  2. “信任任何证书”默认关闭,因此手机的判断会原样返回。每次调用时,都会从 NSUserDefaults 读取这项设置。
SecTrustEvaluate 上的 hook 会返回手机自己的判断,除非“信任任何证书”已开启。

流式回复也必须适应这部手机。AirBuild3G 在后台线程接收并解码数据流,只把已经完成的文本传给界面。对话记录会按固定间隔刷新,而不是每收到一个 token 就重新测量并滚动,从而在回复到达时减少布局工作。能收到回复,说明连接正常。要编译模型写出的内容,仍需在手机上安装环境。

安装手机能运行的编译器

“设置”→“环境”会从环境包中安装编译器、SDK 头文件、链接库和签名工具。iOS 4 软件包附带用于 armv6 的 GCC,iOS 5 及更高版本的软件包附带用于 armv7 的 clang。每部手机只会安装与自身固件匹配的那一个。

每个软件包都通过下载地址和校验和锁定,安装时会使用这些确切文件,而不是向软件仓库请求最新版本。已经安装锁定版本的软件包会被跳过,因此第二次运行“环境”会立即完成。随后,已安装的软件包会被设为保留状态,Cydia 便不会再提供可能破坏工具链的升级。

对于 iPhone 3G,环境使用 2008 年构建的 iphone-gcc 4.2。2009 年的构建版本为预编译头文件预留 1 GB 空间,其中一部分会超出 armv6 手机的地址空间。它的编译器后端还没开始编译就会崩溃。两个构建版本都标识为 Apple build 5555,但只有 2008 年的版本能装进这段地址空间。

armv6 上限0x30000000

2008 iphone-gcc · 每个段均小于 6 MB可运行

512 MB 数组 · 测试二进制文件可运行

768 MB 数组 · 测试二进制文件SIGBUS

2009 iphone-gcc · 1 GB 预编译头文件区域SIGBUS

2008 年的编译器在 armv6 上限以内;2009 年的构建版本则因为预留区域越过上限,在进入 main 前就会失败。512 MB 和 768 MB 的测试数组分别落在上限两侧。

SDK 头文件也需要修改:这个 GCC 无法解析其中的 block 声明。在打包 iOS 4 头文件之前,我们会移除这些声明,以及依赖任何已移除类型的声明。clang 软件包会原样保留 Apple 的头文件,因为 clang 支持 block。每个软件包还附带一个签名工具,可以为对应编译器生成的产物签名。

手机将系统框架保存在 dyld 共享缓存中,因此框架目录里没有链接器可读取的库文件。我们提供生成的存根,其中包含导出符号和真实库名称。链接器通过存根解析每个引用,并记录它来自哪个库;App 启动时,dyld 再把它绑定到共享缓存中的真实实现。由于存根包含真实的符号名称,漏掉某个框架会导致链接失败,而不是等到 App 启动时才崩溃。

环境还会设置 include 路径,将 gcc 和 cc 指向已安装的编译器,并导出链接标志以引入 Objective-C 运行时和 CoreFoundation。附带的 Makefile 会读取这些设置,因此新项目无需自行设置工具链。从这时起,你的 App 就能直接在手机上构建。

template/Makefile
build/%.o: src/%.m | build
	@echo "  compile $<"
	@gcc -c $(IOS_CFLAGS) $< -o $@

# Compiling and linking take different flags, so they are separate steps.
# A missing -framework is a link error here, which is the point: link stubs
# with real symbol names are what make it one instead of a crash at launch.
build/$(NAME): $(OBJECTS)
	@echo "  link    $@"
	@gcc $(OBJECTS) -o $@ $(IOS_LDFLAGS) $(addprefix -framework ,$(FRAMEWORKS))
	@# Unsigned is SIGKILL at launch, not a warning.
	@ldid -S $@
  1. IOS_CFLAGS 和 IOS_LDFLAGS 来自环境安装的 /etc/profile.d/airbuild.sh,因此任何项目都无需指定 SDK 或编译器路径。
  2. 二进制文件也在手机上签名。没有签名,它会在启动时被终止。
这是新项目采用的初始 Makefile。手机只依靠自己安装的工具链完成编译、链接和签名。

创建并运行你的第一个 App

新项目从附带的 UIKit 模板开始。AirBuild3G 会把模板复制到项目目录,并将其中的文件放入模型上下文。模型接手的是一个已经能构建的项目,所以你的第一个请求可以直接描述 App,而不必先让它编写 Makefile。

一个适合作为起点的 App,是做个按钮,每点一次就让屏幕上的数字加一。它足够小,可以完整读完,又能覆盖在手机上编辑、编译、打包和运行的全过程。在请求中,请将 iOS 4 指定为目标,并让模型构建和安装 App。界面代码必须只使用手机所装 iOS 已经实现的 API。头文件中存在声明,不代表该 API 在运行时也存在。

模型有四个工具。Execute 在项目目录中运行命令。EditFile 写入完整文件。PatchFile 替换一段完全匹配的内容。CheckBuild 在加载环境设置后运行 make,并返回输出末尾。工具结果会返回给模型,模型可以继续工作,也可以修正失败的改动。推理始终在你的模型服务上进行。

远程模型你的端点

工具调用

工具结果

手机上工具Execute EditFile PatchFile CheckBuild

这个循环会不断重复,直到模型在不调用工具的情况下回复,你轻点“停止”,或者已经运行 16 轮。

一轮工具调用。模型在远程运行,工具在手机上运行;如果循环继续,每个结果都会放入下一次请求。

新 App 安装完成后,在手机上打开它。对于计数器,只需轻点按钮并看着数字变化,就能准确检查它是否完成了你的要求。之后的请求可以更改布局或添加其他交互,模型会继续使用现有文件和之前的工具结果。

AirBuild3G 会把每个项目及其对话保存在手机上,每轮工具调用结束后都会存储。它还会记住当前打开的项目,因为离开 App 或内存不足都可能终止其进程。重新打开 AirBuild3G 时,它会回到该项目,并恢复对话和文件。你的下一条消息会接着已保存的对话继续,源代码也还在那里,可以继续编辑和构建。

在这块屏幕上,哪怕只是这样一个计数器,也把你曾经使用的 App 和如今可以亲手做出的 App 连在了一起。

熟悉屏幕上的新事物

iPhone 3G 于 2008 年问世,恰逢 App Store 开放。一个新的 App 能让它变成随身携带的乐器、记事本或游戏机。按下主屏幕按钮,你会回到图标网格,每个图标都在邀请你试试别的东西。

今天再用这部手机,你会感到 2008 年已经过去很久。显示屏没有变大,处理器没有变快,原有界面仍要求 App 仔细利用少量控件和有限空间。在这些限制下工作,你会注意到一些在新设备上容易忽略的细节:按钮有没有响应,对话能不能顺畅滚动,下一个屏幕有没有在恰当的时候出现。

AirBuild3G 让这块熟悉的屏幕又多了一项工作。当前的模型帮你编写 App,来自手机所处时代的编译器负责构建。那些旧图标可以留在原处。

它们旁边,还有空间放下一个从未存在过的 App。