32 Commits
Author SHA1 Message Date
shenleiandClaude Opus 5 64715a2c9e feat: 腾讯 AppID 自动补全 tencent 前缀
新增/编辑 App 时,腾讯 AppID 允许只填数字,后端统一归一化为
tencent 前缀;前端输入框失焦即时补全,保存前再校正一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 12:27:36 +09:00
shenleiandClaude Opus 5 21542c88d7 feat: 版本号按分支轨道分开管理,版本号配置收归管理员
master/develop 共用 release 轨(App_Store 打包构建号自增),feature 分支走
feature 轨(构建号永不自增,始终使用手填值),两条轨道的 App_Ver 各自独立。

- versions 改为 tracks 结构,旧的单轨/单值配置在加载时自动迁移进 release 轨
- 新增 config["branch_track"] 记录分支归属,缺省按分支名推断,可在分支管理页改
- 新增 PUT /api/config/branches/track;分支名含 / 时走请求体而非路径参数
- 修复 DELETE /api/config/branches/{name} 无法删除含 / 的分支(405)
- 版本号接口不再对普通用户开放,打包设置页整页改为管理员可见

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 17:56:10 +09:00
shenleiandClaude Opus 4.8 3cb4053ab5 chore: 运行时 config.json 不再纳入 git 跟踪,改用 config.example.json 模板
config.json 是运行时高频写入的数据文件(网页改配置、每次 App_Store 打包递增
构建号都会写盘),纳入版本控制会导致服务器每次 git pull 与本地生产配置冲突。

- git rm --cached backend/data/config.json(保留磁盘文件,仅移出索引)
- .gitignore 显式忽略 backend/data/config.json 并补充说明
- 新增 config.example.json 作为结构模板(不含真实密钥/生产数据)
- README 说明其为运行时文件、pull 不覆盖,及从模板初始化的方式

首次部署仍可通过根目录 config.json 自动导入为 backend/data/config.json。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 17:12:09 +09:00
shenleiandClaude Opus 4.8 91e9b027e8 feat: 构建号按版本号(App_Ver)独立记录,避免回退旧版本号被 App Store 拒
将 versions.build_ver 单值改为 build_map(每个 App_Ver 独立记录已用到的最大
构建号)。App Store 的构建号唯一性按 MARKETING_VERSION 分别计算,改为单一全局
计数器时回退到打过包的旧版本号会重复(如 2.195.0 已到 .5,切 2.196.0 后再回退,
重置/沿用全局计数都会 < .5 而被拒)。

- config.py: 新增 _ensure_versions 迁移旧 build_ver → build_map;PUT /versions
  不再重置构建号,改版本号仅切换当前 App_Ver,并支持可选手动设置 build_ver;
  GET /versions 返回 build_map 及当前版本号对应的 build_ver
- build_service.py: App_Store 打包时在 build_map[app_ver] 自身上 +1 并回写
- ConfigView.vue: 打包设置页显示可编辑的 Build_Ver,修改 App_Ver 时自动切换为
  该版本号已记录的构建号
- 迁移 config.json;更新前后端相关测试

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 16:49:14 +09:00
shenleiandClaude Opus 4.8 b6e56f5f9a docs: README 补充一键部署(up)、就绪探测与 Git 推送说明
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:58:22 +09:00
shenleiandClaude Opus 4.8 b2982ae8f7 feat: deploy.sh 支持一键部署与就绪探测
- 新增 up 子命令:build + start + watchdog 一键强制重来
- start 改为轮询 /api/health 就绪才返回,消除启动竞态误报
- start 增加端口占用守卫:非本项目进程占用时明确报错
- watchdog 增加启动宽限(WATCHDOG_STARTUP_GRACE),避开启动窗口误重启
- watchdog 支持 launchd 模式(WATCHDOG_RESTART_MODE),用 launchctl
  kickstart 重启以避免与 KeepAlive 抢端口
- 抽取 _spawn_server/_wait_ready/foreign_port_holder 公共函数

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:50:59 +09:00
shenleiandClaude Opus 4.8 9d766a0b58 fix: 皮肤包替换忽略 __MACOSX 及隐藏文件
macOS 压缩产生的皮肤 zip 顶层含 __MACOSX 目录,导致解压后判定为
多目录、退回使用解压根目录作 THEME,从而把 __MACOSX 和真实皮肤目录
一并复制进 Images.xcassets。解压统计顶层条目时过滤 __MACOSX 与隐藏
文件,并在 _apply_theme 复制时兜底跳过,使 THEME 正确下钻到真实皮肤
目录。旧皮肤包不含这些条目,行为不变,完全兼容。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:32:45 +09:00
shenleiandClaude Opus 4.8 605c09c88a feat: 皮肤包上传区支持拖拽上传
- 抽出 uploadSkinFile 复用点击与拖拽两种上传方式
- 拖入时高亮上传区并提示,松开即触发上传

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:20:04 +09:00
shenleiandClaude Opus 4.8 5014376b81 feat: 支持皮肤包就地更新与新建即传
- 新增皮肤包更新接口,覆盖内容保持文件名不变,避免 APP 配置的主题引用失效
- 上传新皮肤后自动为空主题目录的证书选中该皮肤
- 新建 APP 保存后不关弹窗并回填 upload_key,可直接上传皮肤

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:14:08 +09:00
shenlei bb8a0afeac feat: 自动隐藏被覆盖的打包历史 2026-07-24 12:05:37 +09:00
shenlei 4dccc4d96b feat: 增加操作审计与受控下载 2026-07-22 18:12:19 +09:00
shenlei 624f534b0f feat: 限制历史操作为管理员可见 2026-07-20 17:18:00 +09:00
shenlei 13366ab3b4 fix: 修复 OSS 删除与历史筛选 2026-07-20 17:09:28 +09:00
shenlei 44405e589d feat: 优化打包产物与历史筛选 2026-07-20 16:55:17 +09:00
shenlei de33b3d4b2 feat: 按环境前缀生成 Apps ID 2026-07-20 16:24:13 +09:00
shenlei 8747792a4b feat: 根据应用版本自动重置构建版本 2026-07-20 16:03:59 +09:00
shenlei 89fb633b00 feat: 普通用户可维护应用版本号 2026-07-20 15:42:52 +09:00
shenlei 51d7374f2d fix: 提示打包提交并重置表单 2026-07-20 15:28:02 +09:00
shenlei 9b77441ec7 fix: 上传二维码并发送 Ad Hoc 通知 2026-07-20 15:22:44 +09:00
shenlei 8c0bce45a4 fix: 将密码最小长度调整为 11 位 2026-07-20 15:11:02 +09:00
shenlei cf6cc1d21a fix: 打包副本包含 AutoPacking 脚本 2026-07-20 15:03:21 +09:00
shenlei cede7978e4 fix: 输出完整归档日志 2026-07-20 15:00:30 +09:00
shenlei f0c6a2d9b6 fix: 完整显示打包历史信息 2026-07-20 14:57:49 +09:00
shenlei 3e3e4123d0 fix: 完善打包历史与登录提示 2026-07-20 14:54:06 +09:00
shenlei c209ae423c fix: 兼容新版 Xcode 的 AFNetworking 头文件 2026-07-20 14:37:04 +09:00
shenlei 2ad5f208ed fix: 统一打包副本 Podfile 大小写 2026-07-20 14:34:18 +09:00
shenlei 2661435273 修复打包签名并支持 App Store IPA 上传 2026-07-20 14:16:45 +09:00
shenlei 7fd3845447 fix: disable git credential cache during builds 2026-07-20 11:45:26 +09:00
shenandshen a48909f3bc feat: 强化打包配置与服务安全 2026-07-18 13:30:56 +08:00
shenandshen d1f070b251 feat: 服务端集成自动打包流程 2026-07-17 22:34:04 +08:00
shenandshen a2437a039d chore: 提交默认配置和皮肤包数据,方便部署 2026-06-09 10:25:52 +08:00
shenandshen 9384062b38 feat: 皮肤包上传管理功能,持久化数据目录
- 新增皮肤包上传/列表/删除 API(基于 upload_key)
- 构建时自动解压 ZIP 皮肤包应用到项目
- 配置和皮肤包迁移到 backend/data/ 目录,切分支不会丢失
- 旧式目录皮肤自动迁移为 ZIP 格式
- 前端皮肤包管理 UI(上传、删除、下拉选择)
- .gitignore 排除运行时数据目录
2026-06-09 10:24:14 +08:00
268 changed files with 10757 additions and 858 deletions
+29 -13
View File
@@ -1,31 +1,46 @@
# === iOS 自动打包服务配置 ===
# 复制此文件为 .env 并修改为实际值
# ---- 项目路径 ----
# iOS 源码项目根目录(也用于 AutoPacking 脚本路径)
PROJECT_ROOT=/Users/shen/Work/Code/Readoor
# ---- 目录配置 ----
# 源码根目录;未配置远程仓库时,每个分支一个子目录
GIT_SOURCE_BASE=./ReadoorBranches
# 配置远程仓库时使用的唯一共享源码工作目录;服务会串行切分支、清理并复制快照
GIT_SOURCE_DIR=./ReadoorBranches/workspace
# 打包输出基础目录(每次打包会在此目录下创建子目录)
BUILD_BASE_DIR=/Users/shen/Documents
BUILD_BASE_DIR=./build
# ---- 分支源码管理 ----
# 分支源码根目录,每个分支一个子目录(如 ReadoorBranches/main、ReadoorBranches/dev
GIT_SOURCE_BASE=/Users/shen/Work/Code/ReadoorBranches
# Git 远程仓库地址,分支目录不存在时自动 clone
GIT_REMOTE_URL=git@github.com:your-org/readoor.git
# Git 远程仓库地址;配置后所有分支共用 GIT_SOURCE_DIR
GIT_REMOTE_URL=https://pineapple.readoor.cn:9080/trn/triapp.git
# 私有仓库:使用只读 Personal Access Token,不要使用账号登录密码。
GIT_USERNAME=your-git-username
GIT_PASSWORD=your-read-repository-token
# ---- 服务端口 ----
BACKEND_PORT=8000
FRONTEND_PORT=3000
BACKEND_PORT=5002
FRONTEND_PORT=5999
# ---- 管理员账号 ----
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
ADMIN_PASSWORD=change-this-before-starting
# ---- JWT 认证 ----
# JWT 签名密钥(生产环境务必修改为随机字符串)
JWT_SECRET=ios-build-server-secret-key-change-in-production
JWT_SECRET=replace-with-a-random-string-of-at-least-32-characters
# ---- 公网部署 ----
# production 会拒绝默认密钥、通配 CORS 和未配置的可信主机。
APP_ENV=production
# Uvicorn 只监听本机,由 Nginx/Caddy 提供 HTTPS 和公网访问。
BIND_HOST=127.0.0.1
CORS_ALLOWED_ORIGINS=https://build.example.com
TRUSTED_HOSTS=build.example.com
# 登录失败 5 次后锁定 15 分钟。
LOGIN_RATE_LIMIT_MAX_ATTEMPTS=5
LOGIN_RATE_LIMIT_WINDOW_SECONDS=300
LOGIN_RATE_LIMIT_LOCKOUT_SECONDS=900
# ---- 并发与清理 ----
# 最大并行打包数
@@ -36,6 +51,7 @@ BUILD_DIR_RETENTION_HOURS=24
# 打包超时时间(小时),超时自动标记失败
BUILD_TIMEOUT_HOURS=1
DOWNLOAD_URL_EXPIRE_SECONDS=600
# ---- Watchdog 健康监测 ----
# 健康检查间隔(秒)
+9
View File
@@ -10,6 +10,15 @@ __pycache__/
# 数据库
*.db
# 运行时数据
backend/data/*.bak
# 运行时配置:网页 UI 改配置、每次 App_Store 打包都会写盘,不纳入版本控制。
# 结构参见仓库根目录的 config.example.json;首次部署可 cp 一份作为初始种子。
config.json
backend/data/config.json
ReadoorBranches/
build/
# 日志
logs/
+35
View File
@@ -0,0 +1,35 @@
# iOS Build Server
FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store builds with web UI, real-time logs, multi-user auth, and health monitoring.
## Tech Stack
- **Backend**: Python 3.9+, FastAPI, SQLAlchemy (SQLite), JWT auth
- **Frontend**: Vue 3, Vite, Vue Router
- **Tests**: pytest (backend), vitest (frontend)
- **Deploy**: macOS launchd, deploy.sh management script
## Key Commands
```bash
./start.sh # Dev mode (backend + frontend hot reload)
./deploy.sh build # Install deps + build frontend
./deploy.sh start/stop/restart/status
./deploy.sh test # Run all tests (backend + frontend)
```
## Architecture
- `backend/routers/` — API routes: auth, users, config, apps, tasks
- `backend/services/` — build_service (packaging), build_queue (async queue), log_streamer (WebSocket)
- `backend/deps.py` — JWT auth dependency used by all protected routes
- `backend/config.py` — loads .env, defines all config constants
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI
## Conventions
- All backend routes require JWT auth except `/api/auth/login` and `/api/health`
- Admin-only routes: user management (`/api/users/*`)
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
- Chinese UI and documentation throughout
+3 -2
View File
@@ -25,11 +25,12 @@ FastAPI + Vue 3 iOS app auto-packaging service. Supports Ad_Hoc and App_Store bu
- `backend/deps.py` — JWT auth dependency used by all protected routes
- `backend/config.py` — loads .env, defines all config constants
- `frontend/src/views/` — BuildView, HistoryView, ConfigView
- `config.json` — runtime config (apps, schemes, servers, upload, branches) managed via web UI
- `config.json` — runtime config (apps, schemes, servers, upload, branches, versions) managed via web UI
- Versions are per-track: `release` (master/develop, build number auto-increments on App_Store) and `feature` (self-test, never increments); each branch's track lives in `config["branch_track"]`
## Conventions
- All backend routes require JWT auth except `/api/auth/login` and `/api/health`
- Admin-only routes: user management (`/api/users/*`)
- Admin-only routes: user management (`/api/users/*`) and all of `/api/config/*` except `/api/config/apps*`
- Config stored in `config.json` (apps/schemes/servers), `.env` (infra), SQLite `build_config` table (build settings)
- Chinese UI and documentation throughout
+132 -35
View File
@@ -7,11 +7,13 @@
- [环境要求](#环境要求)
- [快速开始](#快速开始)
- [配置说明](#配置说明)
- [公网部署](#公网部署)
- [服务管理](#服务管理)
- [多用户管理](#多用户管理)
- [Watchdog 健康监测](#watchdog-健康监测)
- [macOS 开机自启](#macos-开机自启)
- [开发模式](#开发模式)
- [推送代码到 Git 服务器](#推送代码到-git-服务器)
- [自动化测试](#自动化测试)
- [项目结构](#项目结构)
- [常见问题](#常见问题)
@@ -44,14 +46,17 @@ cp .env.example .env
vim .env
```
**必须修改的配置项:**
**建议优先检查的配置项:**
```bash
# iOS 源码项目路径(包含 readoor.xcworkspace 的目录)
PROJECT_ROOT=/path/to/your/Readoor
# 分支源码根目录,默认在当前项目下
GIT_SOURCE_BASE=/path/to/iOSBuildServer/ReadoorBranches
# 打包输出目录
BUILD_BASE_DIR=/path/to/build/output
# 打包输出目录,默认在当前项目下
BUILD_BASE_DIR=/path/to/iOSBuildServer/build
# Git 远程仓库地址(配置后可自动拉取分支)
GIT_REMOTE_URL=git@github.com:org/repo.git
# 管理员密码(建议修改)
ADMIN_PASSWORD=your_secure_password
@@ -59,29 +64,44 @@ ADMIN_PASSWORD=your_secure_password
### 3. 构建与启动
推荐一键部署(构建 + 启动 + 健康监测,一步到位):
```bash
# 一键部署:build + start + watchdog(强制重来)
./deploy.sh up
```
`up` 会自动构建前端、(强制)重启后端并**轮询 `/api/health` 直到就绪**才返回,再拉起 watchdog,最后打印状态。适合首次部署和更新代码后重新上线。
> ⚠️ `up`/`start` 采用「强制重来」语义:若检测到旧服务会先停再起,正在进行的打包任务会被中断。
也可以分步执行:
```bash
# 一键构建(安装依赖 + 构建前端)
./deploy.sh build
# 启动服务
# 启动服务(等待就绪;端口被非本项目进程占用时会明确报错)
./deploy.sh start
```
启动后访问 `http://<服务器IP>:8000` 即可使用。
### 4.(可选)初始化分支源码目录
### 4.(可选)初始化源码目录
如果需要按分支打包,先准备分支源码目录:
配置 `GIT_REMOTE_URL` 后,系统默认使用唯一的共享源码目录 `GIT_SOURCE_DIR`。任务会串行执行拉取、切分支、清理和复制,复制完成后在各自的打包目录中并行构建。
未配置远程仓库时,需要继续手动准备每个分支目录:
```bash
# 创建分支源码根目录
mkdir -p /path/to/ReadoorBranches
# 手动 clone 主分支(后续自动 pull 更新
# 手动 clone 主分支(后续可继续手动维护
git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
```
其他分支无需手动 clone首次打包时会自动从远程仓库 clone
配置远程仓库时,无需手动 clone首次打包会初始化共享目录
### 5.(可选)启用健康监测
@@ -101,14 +121,16 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
所有配置通过项目根目录的 `.env` 文件管理,修改后重启服务生效。
### 项目路径
### 目录配置
| 变量 | 示例 | 说明 |
|------|------|------|
| `PROJECT_ROOT` | `/Users/shen/Work/Code/Readoor` | iOS 源码项目根目录(AutoPacking 脚本路径) |
| `BUILD_BASE_DIR` | `/Users/shen/Documents` | 打包产物输出基础目录 |
| `GIT_SOURCE_BASE` | `/path/to/ReadoorBranches` | 分支源码根目录,每个分支一个子目录 |
| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,分支目录不存在时自动 clone |
| `GIT_SOURCE_BASE` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches` | 人工维护模式下的分支源码根目录 |
| `GIT_SOURCE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/ReadoorBranches/workspace` | 配置远程仓库时唯一的共享源码工作目录 |
| `BUILD_BASE_DIR` | `/Users/shen/Work/Code/iOSBuildServer/build` | 打包产物输出基础目录 |
| `GIT_REMOTE_URL` | `git@github.com:org/repo.git` | 远程仓库地址,首次构建时自动初始化共享源码目录 |
私有仓库请创建仅有 `read_repository` 权限的 Personal Access Token,并配置为 `GIT_PASSWORD``GIT_USERNAME` 使用该 Token 所属账号。不要在 `.env``config.json` 或 Git 仓库中保存账号登录密码。
### 服务配置
@@ -119,6 +141,25 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
| `ADMIN_PASSWORD` | `admin123` | 初始管理员密码 |
| `JWT_SECRET` | `ios-build-server-secret-key-change-in-production` | JWT 签名密钥(生产环境务必修改) |
## 公网部署
不要将 Uvicorn 端口直接暴露到公网。使用 Nginx 或 Caddy 提供 HTTPS,服务仅监听 `127.0.0.1`。项目提供了 [nginx.conf.example](/Users/shen/Work/Code/iOSBuildServer/deploy/nginx.conf.example) 作为模板。
公网 `.env` 至少需要如下配置:
```bash
APP_ENV=production
BIND_HOST=127.0.0.1
JWT_SECRET=<至少 32 位随机字符串>
ADMIN_PASSWORD=<至少 12 位强密码>
CORS_ALLOWED_ORIGINS=https://build.example.com
TRUSTED_HOSTS=build.example.com
GIT_USERNAME=<只读 Token 所属账号>
GIT_PASSWORD=<仅 read_repository 权限的 Personal Access Token>
```
设置完成后执行 `chmod 600 .env`,并在云安全组和系统防火墙中只开放 `80/443``APP_ENV=production` 会在默认 JWT、弱管理员密码、通配 CORS 或未设置可信域名时拒绝启动。
### 打包配置
| 变量 | 默认值 | 说明 |
@@ -129,16 +170,42 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
### Web 管理配置
以下配置通过管理后台 Web 界面管理(存储在 `config.json`):
以下配置通过管理后台 Web 界面管理(存储在 `backend/data/config.json`):
> ⚠️ `config.json` 是**运行时数据文件**(网页改配置、每次 App_Store 打包写入构建号都会改写它),**不纳入版本控制**,`git pull` 不会覆盖它。仓库仅提供结构模板 `config.example.json`。
>
> 首次部署时,若项目根目录下存在 `config.json`,系统会自动将其复制为 `backend/data/config.json` 作为初始配置。可从模板起步:
>
> ```bash
> cp config.example.json config.json # 按需填入真实的 apps / servers / upload 等,再启动
> ```
>
> 导入后即可继续在后台增删改。
| 配置项 | 说明 |
|--------|------|
| App 管理 | 应用名称、Scheme、服务器环境、upload_key 等 |
| Scheme 管理 | 打包 Scheme 配置 |
| 服务器环境 | 测试/正式/自定义环境的 API 地址、Universal Link 等 |
| 分支管理 | 可打包的代码分支列表 |
| 版本号 | App_Ver 和 Build_Ver(写入 AutoPacking 脚本 |
| 上传配置 | OSS / WebDAV 文件上传设置 |
| 分支管理 | 可打包的代码分支列表,以及每个分支所属的版本轨道 |
| 版本号 | 服务端统一管理 App_Ver 和 Build_Ver,不修改分支源码;按版本轨道分开管理(见下 |
| 上传配置 | 服务端内置 OSS / WebDAV 分发,生成 IPA、manifest、下载页和二维码 |
### 版本号与版本轨道
版本号按 **轨道(track** 分开管理,两条轨道的 App_Ver 各自独立填写、互不影响:
| 轨道 | 适用分支 | 构建号行为 |
|------|----------|-----------|
| `release`(上架) | master、develop(共用同一套版本号与构建号序列) | App_Store 打包时按当前 App_Ver 自动 +1 |
| `feature`(自测) | feature/* 等其他分支 | 永不自增,始终使用配置页填写的值(默认 `.0` |
- 分支归属默认按名字推断(master/main/develop/dev/release-*/hotfix-* → release,其余 → feature),可在「分支管理」页逐个改。
- 每条轨道内的构建号按 App_Ver 独立记录(`build_map`),回退到打过包的旧版本号时会从它自己的最大值继续,不会与 App Store Connect 上已传构建号冲突。
### 服务端自动化资源
项目内的 `backend/automation/` 是 Web 打包服务唯一使用的自动化资源:默认皮肤和混淆工具都在此处维护。分支源码中的 `AutoPacking/` 不会被复制、执行或修改,仍可供开发者手动打包使用。
### Watchdog 配置
@@ -146,6 +213,9 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
|------|--------|------|
| `WATCHDOG_INTERVAL` | `30` | 健康检查间隔(秒) |
| `WATCHDOG_TIMEOUT` | `10` | HTTP 健康检查超时(秒) |
| `WATCHDOG_STARTUP_GRACE` | `10` | 首次检查前的宽限(秒),避开服务正常启动窗口,防止误重启 |
| `STARTUP_READY_TIMEOUT` | `20` | `start` 后等待 `/api/health` 就绪的最长时间(秒) |
| `WATCHDOG_RESTART_MODE` | `nohup` | 重启方式:`nohup`PID 文件托管)或 `launchd`(用 `launchctl kickstart` 交回 launchd 托管,避免与 KeepAlive 抢端口) |
| `MAX_RESTART_ATTEMPTS` | `3` | 连续重启上限,超过后进入冷却 |
| `COOLDOWN_SECONDS` | `300` | 冷却时间(秒) |
@@ -163,8 +233,9 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
| 命令 | 说明 |
|------|------|
| `up` | **一键部署**build + start + watchdog(强制重来),启动后等待就绪 |
| `build` | 构建前端、创建 Python 虚拟环境、安装所有依赖 |
| `start` | 后台启动服务 |
| `start` | 后台启动服务(强制重启并等待 `/api/health` 就绪;端口被非本项目进程占用会报错) |
| `stop` | 停止服务和 watchdog |
| `restart` | 重启服务 |
| `status` | 查看服务和 watchdog 运行状态 |
@@ -176,18 +247,17 @@ git clone -b main git@github.com:org/repo.git /path/to/ReadoorBranches/main
### 常用操作
```bash
# 首次部署
./deploy.sh build && ./deploy.sh start
# 首次部署(一键:build + start + watchdog
./deploy.sh up
# 查看状态
./deploy.sh status
# 更新代码后重新部署
git pull
./deploy.sh build
./deploy.sh restart
./deploy.sh up
# 启用健康监测
# 单独启用健康监测
./deploy.sh watchdog
# 停止所有服务
@@ -219,7 +289,7 @@ logs/
| 角色 | 权限 |
|------|------|
| 管理员 | 所有功能 + 用户管理(增删改查、角色切换) |
| 普通用户 | 打包、查看历史、查看配置(不可修改配置、不可管理用户) |
| 普通用户 | 打包、查看历史、管理 Apps 配置(不可修改版本号及其余配置、不可管理用户) |
### 管理用户
@@ -337,6 +407,31 @@ launchctl load ~/Library/LaunchAgents/com.readoor.buildserver.watchdog.plist
---
## 推送代码到 Git 服务器
本项目托管在自建 Git 服务(`origin` 走 HTTPS),推送需要账号凭据:
```bash
# 提交改动
git add -A
git commit -m "feat: 描述你的改动"
# 推送到远程 main
git push origin main
```
首次推送会提示输入用户名和密码/访问令牌。若不想每次都输入,可启用 macOS 钥匙串缓存凭据(只需配置一次):
```bash
git config --global credential.helper osxkeychain
```
配置后,下一次 `git push` 输入的账号密码会被安全保存到钥匙串,后续免输入。
> 若 Git 服务使用**访问令牌**认证:用户名填账号、密码填令牌即可。
---
## 自动化测试
### 运行测试
@@ -496,7 +591,7 @@ tail -50 logs/server.log
```
常见原因:
- `PROJECT_ROOT` 路径不存在
- `GIT_SOURCE_BASE` 下对应分支目录不存在,且未配置 `GIT_REMOTE_URL`
- 磁盘空间不足
- Python 依赖缺失(重新执行 `./deploy.sh build`
@@ -531,22 +626,24 @@ cp .env.example .env
### Q: 如何按分支打包
1.`.env` 中配置 `GIT_SOURCE_BASE``GIT_REMOTE_URL`
1.`.env` 中配置 `GIT_SOURCE_BASE``GIT_SOURCE_DIR``GIT_REMOTE_URL`
2. 在管理页面「分支管理」中添加需要打包的分支(默认已有 `main`
3. 打包时在「代码分支」下拉框中选择分支
4. 首次打包某个分支时会自动从远程 clone,后续打包会自动 `git pull` 更新
5. 每个分支有独立的源码目录,支持并行打包不同分支
4. 首次打包会初始化共享源码目录;每次任务会串行 `git fetch`、切换目标分支、清理并复制源码快照
5. 快照复制完成后,依赖安装和 Xcode 打包仍在独立目录中并行执行
### Q: 分支目录占满磁盘怎么办
### Q: 源码目录占满磁盘怎么办
```bash
# 查看各分支目录大小
du -sh /path/to/ReadoorBranches/*
# 远程仓库模式下只有一个共享工作目录
du -sh /path/to/ReadoorBranches/workspace
# 删除不用的分支目录
rm -rf /path/to/ReadoorBranches/old-branch
# 打包目录由 BUILD_DIR_RETENTION_HOURS 自动清理;可查看其占用
du -sh /path/to/build/build_readoor_*
```
共享工作目录由服务在每次打包前自动同步和清理。请勿在服务运行期间手动删除或修改该目录。
### Q: 如何运行测试
```bash
@@ -0,0 +1,202 @@
#!/usr/bin/env python3
"""
构建期 dylib 白名单生成脚本
优先扫描最终 .app Bundle 内实际嵌入并会被 dyld 加载的镜像,生成:
1. RDKnownImages.plist — 白名单 + HMAC-SHA256 摘要
2. Generated/RDWhitelistSecret.swift — 仅在缺失时补生成
在 Xcode Build Phase 中调用时,会复用现有密钥并更新最终 app bundle 内的 plist
手动执行时若无法定位 app bundle,则退回扫描仓库中的 framework/dylib 来源。
"""
import os
import sys
import hmac
import hashlib
import plistlib
import secrets
import re
from pathlib import Path
# 所有 framework 来源目录(相对于项目根目录)
FRAMEWORK_SEARCH_PATHS = [
'Pods',
'readoor/BookShelf/Classes/Factorys',
'readoor/3.0/Factorys',
'readoor/3.0/Features/ThirdPartyService',
]
# 输出路径
PLIST_OUTPUT = 'readoor/3.0/Base/Security/RDKnownImages.plist'
SECRET_OUTPUT = 'readoor/3.0/Base/Security/Generated/RDWhitelistSecret.swift'
def scan_source_frameworks(search_paths, app_name):
"""扫描仓库中的 framework 和 dylib 来源,作为无法定位 app bundle 时的兜底方案。"""
known = []
seen = set()
for search_dir in search_paths:
if not os.path.isdir(search_dir):
continue
for root, dirs, files in os.walk(search_dir):
# .framework 在 macOS 上是目录,出现在 dirs 中
for d in dirs:
if d.endswith('.framework'):
basename = d.replace('.framework', '')
rel_path = os.path.relpath(os.path.join(root, d), '.')
key = (basename, rel_path)
if key not in seen:
seen.add(key)
known.append({'name': basename, 'path': rel_path})
for f in files:
if f.endswith('.dylib'):
basename = f.replace('.dylib', '')
rel_path = os.path.relpath(os.path.join(root, f), '.')
key = (basename, rel_path)
if key not in seen:
seen.add(key)
known.append({'name': basename, 'path': rel_path})
# App 自身
app_key = (app_name, app_name)
if app_key not in seen:
known.append({'name': app_name, 'path': app_name})
# 按 name 排序保证稳定性
known.sort(key=lambda x: x['name'])
return known
def scan_bundled_images(app_bundle_path, executable_path):
"""扫描最终 app bundle 中会实际被加载的镜像,路径使用 bundle 相对路径。"""
known = []
seen = set()
executable_name = Path(executable_path).name if executable_path else Path(app_bundle_path).stem
main_key = (executable_name, executable_path or executable_name)
seen.add(main_key)
known.append({'name': executable_name, 'path': executable_path or executable_name})
for root, dirs, files in os.walk(app_bundle_path):
dirs.sort()
files.sort()
for d in dirs:
if not d.endswith('.framework'):
continue
framework_dir = os.path.join(root, d)
basename = d.replace('.framework', '')
# if basename == 'MJExtension': # 故意排除
# continue
binary_path = os.path.join(framework_dir, basename)
if not os.path.isfile(binary_path):
continue
rel_path = os.path.relpath(binary_path, app_bundle_path)
key = (basename, rel_path)
if key not in seen:
seen.add(key)
known.append({'name': basename, 'path': rel_path})
for f in files:
if not f.endswith('.dylib'):
continue
dylib_path = os.path.join(root, f)
basename = f.replace('.dylib', '')
rel_path = os.path.relpath(dylib_path, app_bundle_path)
key = (basename, rel_path)
if key not in seen:
seen.add(key)
known.append({'name': basename, 'path': rel_path})
known.sort(key=lambda x: (x['name'], x['path']))
return known
def compute_digest(entries, secret):
"""对排序后的白名单内容计算 HMAC-SHA256 摘要"""
parts = [f"{e['name']}|{e['path']}" for e in entries]
message = '\n'.join(parts).encode('utf-8')
return hmac.new(secret, message, hashlib.sha256).hexdigest()
def generate_swift_secret(secret_bytes):
"""将构建期密钥写入 Swift 源码"""
hex_bytes = ', '.join(f'0x{b:02x}' for b in secret_bytes)
source = f"""// Auto-generated by generate_image_whitelist.py — do not edit manually
import Foundation
enum RDWhitelistSecret {{
static let key: [UInt8] = [{hex_bytes}]
}}
"""
os.makedirs(os.path.dirname(SECRET_OUTPUT), exist_ok=True)
with open(SECRET_OUTPUT, 'w') as f:
f.write(source)
def load_existing_secret():
if not os.path.isfile(SECRET_OUTPUT):
return None
with open(SECRET_OUTPUT, 'r', encoding='utf-8') as f:
content = f.read()
hex_values = re.findall(r'0x([0-9a-fA-F]{2})', content)
if not hex_values:
return None
return bytes(int(value, 16) for value in hex_values)
def resolve_app_bundle_path():
target_build_dir = os.environ.get('TARGET_BUILD_DIR')
wrapper_name = os.environ.get('WRAPPER_NAME')
if target_build_dir and wrapper_name:
app_bundle_path = os.path.join(target_build_dir, wrapper_name)
if os.path.isdir(app_bundle_path):
return app_bundle_path
return None
def main():
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.chdir(root)
build_secret = load_existing_secret()
if build_secret is None:
build_secret = secrets.token_bytes(32)
generate_swift_secret(build_secret)
print(f'[whitelist] Generated {SECRET_OUTPUT}')
app_bundle_path = resolve_app_bundle_path()
executable_path = os.environ.get('EXECUTABLE_PATH', 'readoor')
if app_bundle_path:
frameworks = scan_bundled_images(app_bundle_path, executable_path)
print(f'[whitelist] Scanned app bundle images from {app_bundle_path}')
else:
frameworks = scan_source_frameworks(FRAMEWORK_SEARCH_PATHS, 'readoor')
print('[whitelist] App bundle not found, fallback to source framework scan')
digest = compute_digest(frameworks, build_secret)
output = {
'known_images': frameworks,
'digest': digest
}
if app_bundle_path:
destination = os.path.join(app_bundle_path, os.path.basename(PLIST_OUTPUT))
os.makedirs(os.path.dirname(destination), exist_ok=True)
with open(destination, 'wb') as f:
plistlib.dump(output, f)
print(f'[whitelist] Generated app bundle plist {destination} with {len(frameworks)} dynamic entries')
else:
os.makedirs(os.path.dirname(PLIST_OUTPUT), exist_ok=True)
with open(PLIST_OUTPUT, 'wb') as f:
plistlib.dump(output, f)
print(f'[whitelist] Generated {PLIST_OUTPUT} with {len(frameworks)} dynamic entries')
print(f'[whitelist] digest={digest[:16]}...')
return 0
if __name__ == '__main__':
sys.exit(main())
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,144 @@
import hashlib
import json
def build_canonical_symbol_tables(
swift_files: list,
objc_files: list,
method_files: list,
*,
enable_objc_symbols: bool,
prefilter_files,
type_pattern: str,
method_pattern: str,
extract_swift_symbols,
extract_objc_symbols,
extract_methods,
objc_symbol_prefixes: tuple,
already_obfuscated_re,
symbol_whitelist,
symbol_prefix_whitelist: tuple,
is_system_method,
split_swift_signature_key,
) -> dict:
swift_symbol_files = prefilter_files(swift_files, type_pattern)
objc_symbol_files = prefilter_files(objc_files, type_pattern) if enable_objc_symbols else []
method_symbol_files = prefilter_files(method_files, method_pattern)
swift_symbols = set()
objc_symbols = set()
direct_methods = set()
objc_selectors = set()
swift_signatures = {}
for file_path in swift_symbol_files:
swift_symbols.update(extract_swift_symbols(file_path))
for file_path in objc_symbol_files:
objc_symbols.update(extract_objc_symbols(file_path))
for file_path in method_symbol_files:
method_data = extract_methods(file_path)
direct_methods.update(method_data["direct_names"])
objc_selectors.update(method_data["objc_selectors"])
swift_signatures.update(method_data["swift_signatures"])
canonical = {
"swift_symbols": sorted(swift_symbols),
"objc_symbols": [
name for name in sorted(objc_symbols)
if any(name.startswith(prefix) for prefix in objc_symbol_prefixes)
and not already_obfuscated_re.match(name)
and name not in symbol_whitelist
and not name.startswith(symbol_prefix_whitelist)
],
"direct_methods": [
name for name in sorted(direct_methods)
if name and not already_obfuscated_re.match(name) and name not in symbol_whitelist and not is_system_method(name)
],
"objc_selectors": [
name for name in sorted(objc_selectors)
if name and not already_obfuscated_re.match(name) and name not in symbol_whitelist and not is_system_method(name)
],
"swift_signatures": {},
}
for signature_key in sorted(swift_signatures.keys()):
base_name, _ = split_swift_signature_key(signature_key)
if not base_name:
continue
if already_obfuscated_re.match(base_name) or base_name in symbol_whitelist or is_system_method(base_name):
continue
canonical["swift_signatures"][signature_key] = swift_signatures[signature_key]
digest_payload = {
key: canonical[key]
for key in ("swift_symbols", "objc_symbols", "direct_methods", "objc_selectors", "swift_signatures")
}
canonical["digest"] = hashlib.sha256(
json.dumps(digest_payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
).hexdigest()
return canonical
def build_type_mappings_from_canonical_table(canonical_tables: dict, salt: str, *, enable_objc_symbols: bool, build_unique_mapping, type_prefix: str) -> tuple:
ordered_names = []
seen = set()
for bucket_name in ("objc_symbols", "swift_symbols"):
for name in canonical_tables.get(bucket_name, []):
if name in seen:
continue
seen.add(name)
ordered_names.append(name)
combined_mapping = build_unique_mapping(ordered_names, salt, prefix=type_prefix)
mapping_swift = {
name: combined_mapping[name]
for name in canonical_tables.get("swift_symbols", [])
if name in combined_mapping
}
mapping_objc = {}
if enable_objc_symbols:
mapping_objc = {
name: combined_mapping[name]
for name in canonical_tables.get("objc_symbols", [])
if name in combined_mapping
}
return mapping_swift, mapping_objc
def build_method_mapping_from_canonical_table(canonical_tables: dict, salt: str, *, build_unique_mapping, method_prefix: str) -> dict:
swift_signature_mapping = canonical_tables.get("swift_signatures", {})
ordered_names = []
seen = set()
for bucket in (
canonical_tables.get("direct_methods", []),
canonical_tables.get("objc_selectors", []),
sorted(swift_signature_mapping.keys()),
):
for name in bucket:
if name in seen:
continue
seen.add(name)
ordered_names.append(name)
combined_tokens = build_unique_mapping(ordered_names, salt, prefix=method_prefix)
method_mapping = {
name: combined_tokens[name]
for name in canonical_tables.get("direct_methods", [])
if name in combined_tokens
}
method_mapping.update({
name: combined_tokens[name]
for name in canonical_tables.get("objc_selectors", [])
if name in combined_tokens
})
for signature_key in sorted(swift_signature_mapping.keys()):
if signature_key not in combined_tokens:
continue
entry = dict(swift_signature_mapping[signature_key])
entry["obfuscated"] = combined_tokens[signature_key]
method_mapping[signature_key] = entry
return method_mapping
@@ -0,0 +1,143 @@
import os
import shutil
import subprocess
from pathlib import Path
def path_matches_dir_names(path: str, dir_names: tuple) -> bool:
if not dir_names:
return False
wanted = {name.lower() for name in dir_names}
return any(part.lower() in wanted for part in Path(path).parts)
def should_exclude_path(path: str, exclude_path_fragments) -> bool:
return any(fragment in path for fragment in exclude_path_fragments)
def _run_command_capture_stdout(args: list) -> str:
try:
completed = subprocess.run(
args,
capture_output=True,
text=True,
check=False,
)
except Exception:
return ""
if completed.returncode not in (0, 1):
return ""
return completed.stdout
def _discover_files_via_os_walk(scan_dirs: list, allowed_exts: tuple, exclude_path_fragments, skip_dir_names: tuple = (), basename_prefixes: tuple = ()) -> list:
files = []
allowed_exts = set(allowed_exts)
for scan_dir in scan_dirs:
for root, dirs, filenames in os.walk(scan_dir):
dirs[:] = [
directory for directory in dirs
if not any(fragment.strip("/") in directory for fragment in exclude_path_fragments)
]
if path_matches_dir_names(root, skip_dir_names):
continue
for filename in filenames:
ext = os.path.splitext(filename)[1]
if ext not in allowed_exts:
continue
name_no_ext = os.path.splitext(filename)[0]
if basename_prefixes and not any(name_no_ext.startswith(prefix) for prefix in basename_prefixes):
continue
full_path = os.path.normpath(os.path.join(root, filename))
if should_exclude_path(full_path, exclude_path_fragments):
continue
if path_matches_dir_names(full_path, skip_dir_names):
continue
files.append(full_path)
return sorted(set(files))
def _discover_files_via_rg(scan_dirs: list, allowed_exts: tuple, exclude_path_fragments, skip_dir_names: tuple = (), basename_prefixes: tuple = ()) -> list:
rg = shutil.which("rg")
if not rg:
return []
cmd = [rg, "--files", "--no-ignore", "--hidden"]
for ext in allowed_exts:
cmd.extend(["-g", f"*{ext}"])
cmd.extend(scan_dirs)
output = _run_command_capture_stdout(cmd)
if not output:
return []
files = []
for line in output.splitlines():
path = os.path.normpath(line.strip())
if not path or should_exclude_path(path, exclude_path_fragments):
continue
if path_matches_dir_names(path, skip_dir_names):
continue
if basename_prefixes:
name_no_ext = os.path.splitext(os.path.basename(path))[0]
if not any(name_no_ext.startswith(prefix) for prefix in basename_prefixes):
continue
files.append(path)
return sorted(set(files))
def discover_files(scan_dirs: list, allowed_exts: tuple, exclude_path_fragments, skip_dir_names: tuple = (), basename_prefixes: tuple = ()) -> list:
files = _discover_files_via_rg(
scan_dirs,
allowed_exts,
exclude_path_fragments,
skip_dir_names=skip_dir_names,
basename_prefixes=basename_prefixes,
)
if files:
return files
return _discover_files_via_os_walk(
scan_dirs,
allowed_exts,
exclude_path_fragments,
skip_dir_names=skip_dir_names,
basename_prefixes=basename_prefixes,
)
def _chunked(items: list, chunk_size: int):
for index in range(0, len(items), chunk_size):
yield items[index:index + chunk_size]
def prefilter_files_with_rg(files: list, pattern: str, chunk_size: int = 256) -> list:
files = sorted(set(files))
if not files:
return []
rg = shutil.which("rg")
if not rg:
return files
matched = set()
for chunk in _chunked(files, chunk_size):
try:
completed = subprocess.run(
[rg, "-l", "--no-messages", "-e", pattern, *chunk],
capture_output=True,
text=True,
check=False,
)
except Exception:
return files
if completed.returncode not in (0, 1):
return files
for line in completed.stdout.splitlines():
path = os.path.normpath(line.strip())
if path:
matched.add(path)
return sorted(matched)
@@ -0,0 +1,128 @@
import hashlib
import json
import os
from pathlib import Path
def load_json_file(path: str, default):
try:
return json.loads(Path(path).read_text(encoding="utf-8"))
except Exception:
return default() if callable(default) else default
def save_json_file(path: str, payload):
Path(path).write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
)
class IncrementalAnalysisCache:
def __init__(self, cache_path: str, version: int):
self.cache_path = cache_path
self.version = version
self._state = None
self._dirty = False
self._memo = {}
def _default_state(self) -> dict:
return {
"version": self.version,
"files": {},
}
def load_state(self) -> dict:
if self._state is not None:
return self._state
payload = load_json_file(self.cache_path, self._default_state)
if payload.get("version") != self.version or not isinstance(payload.get("files"), dict):
payload = self._default_state()
self._state = payload
return self._state
def save_if_dirty(self):
if not self._dirty:
return
try:
save_json_file(self.cache_path, self.load_state())
self._dirty = False
except Exception:
pass
@staticmethod
def build_cache_key(analysis_kind: str, path: str) -> str:
return f"{analysis_kind}:{os.path.realpath(path)}"
@staticmethod
def _read_text_with_hash(path: str):
try:
text = Path(path).read_text(encoding="utf-8", errors="ignore")
except Exception:
return None, None
return text, hashlib.sha256(text.encode("utf-8")).hexdigest()
def get_or_compute(self, path: str, analysis_kind: str, analyzer):
cache_key = self.build_cache_key(analysis_kind, path)
memo_key = (analysis_kind, os.path.realpath(path))
if memo_key in self._memo:
return self._memo[memo_key]
cache_state = self.load_state()
files_cache = cache_state.setdefault("files", {})
cached_entry = files_cache.get(cache_key)
try:
stat = os.stat(path)
stat_signature = {
"mtime_ns": stat.st_mtime_ns,
"size": stat.st_size,
}
except Exception:
stat_signature = {
"mtime_ns": None,
"size": None,
}
if (
isinstance(cached_entry, dict)
and cached_entry.get("mtime_ns") == stat_signature["mtime_ns"]
and cached_entry.get("size") == stat_signature["size"]
and isinstance(cached_entry.get("analysis"), dict)
):
analysis = cached_entry["analysis"]
self._memo[memo_key] = analysis
return analysis
text, text_hash = self._read_text_with_hash(path)
if text is None:
analysis = analyzer("", path)
self._memo[memo_key] = analysis
return analysis
if (
isinstance(cached_entry, dict)
and cached_entry.get("sha256") == text_hash
and isinstance(cached_entry.get("analysis"), dict)
):
cached_entry.update(stat_signature)
self._dirty = True
analysis = cached_entry["analysis"]
self._memo[memo_key] = analysis
return analysis
analysis = analyzer(text, path)
files_cache[cache_key] = {
"path": os.path.realpath(path),
"analysis_kind": analysis_kind,
"mtime_ns": stat_signature["mtime_ns"],
"size": stat_signature["size"],
"sha256": text_hash,
"analysis": analysis,
}
self._dirty = True
self._memo[memo_key] = analysis
return analysis
@@ -0,0 +1,44 @@
#!/bin/bash
set -euo pipefail
# Xcode Run Script Phase (Post) for Archive obfuscation.
# Add this as a late Build Phase (after Compile Sources / near end).
# It restores original files backed up by archive_obfuscate_pre.sh.
if [[ "${ACTION:-}" != "install" || "${CONFIGURATION:-}" != "Release" ]]; then
echo "[obf-post] Skip: ACTION=${ACTION:-}, CONFIGURATION=${CONFIGURATION:-}"
exit 0
fi
DERIVED_DIR="${DERIVED_FILE_DIR:-/tmp}"
MARKER_FILE="${DERIVED_DIR}/rd_obf_marker_${TARGET_NAME:-app}.env"
if [[ ! -f "${MARKER_FILE}" ]]; then
echo "[obf-post] No marker file found. Skip restore."
exit 0
fi
# shellcheck disable=SC1090
source "${MARKER_FILE}"
if [[ -z "${BACKUP_DIR:-}" || -z "${ROOT:-}" ]]; then
echo "[obf-post] Marker invalid, skip restore."
exit 0
fi
if [[ ! -d "${BACKUP_DIR}" ]]; then
echo "[obf-post] Backup dir missing: ${BACKUP_DIR}"
exit 0
fi
echo "[obf-post] Restoring source files from ${BACKUP_DIR}"
cd "${ROOT}"
while IFS= read -r file; do
rel="${file#${BACKUP_DIR}/}"
mkdir -p "$(dirname "${ROOT}/$rel")"
cp -f "$file" "${ROOT}/$rel"
done < <(find "${BACKUP_DIR}" -type f)
rm -rf "${BACKUP_DIR}" "${MARKER_FILE}"
echo "[obf-post] Done."
@@ -0,0 +1,110 @@
#!/bin/bash
set -euo pipefail
# Xcode Run Script Phase (Pre) for Archive obfuscation.
# Add this as an early Build Phase (before Compile Sources).
# It only runs on Archive + Release, and obfuscates source in-place,
# then the post phase restores original files from backup.
if [[ "${ACTION:-}" != "install" || "${CONFIGURATION:-}" != "Release" ]]; then
echo "[obf-pre] Skip: ACTION=${ACTION:-}, CONFIGURATION=${CONFIGURATION:-}"
exit 0
fi
# Allow external driver (CI/script) to opt-out of running this phase
if [[ "${SKIP_OBF_PHASE:-}" = "1" ]]; then
echo "[obf-pre] SKIP_OBF_PHASE=1 set; skipping pre-archive obfuscation phase."
exit 0
fi
ROOT="${SRCROOT:-$(cd "$(dirname "$0")/../../.." && pwd)}"
DERIVED_DIR="${DERIVED_FILE_DIR:-/tmp}"
STAMP="$(date +%Y%m%d_%H%M%S)"
BACKUP_DIR="${DERIVED_DIR}/rd_obf_backup_${STAMP}"
MARKER_FILE="${DERIVED_DIR}/rd_obf_marker_${TARGET_NAME:-app}.env"
MAP_DIR="${ROOT}/obfuscation_maps"
MAP_PATH="${MAP_DIR}/${TARGET_NAME:-app}_${STAMP}.json"
METHOD_MAP_PATH="${MAP_DIR}/method_map_${TARGET_NAME:-app}_${STAMP}.json"
PRE_SUCCESS=0
restore_from_backup_dir() {
local backup_dir="$1"
if [[ ! -d "${backup_dir}" ]]; then
return 0
fi
while IFS= read -r file; do
rel="${file#${backup_dir}/}"
mkdir -p "$(dirname "${ROOT}/$rel")"
cp -f "$file" "${ROOT}/$rel"
done < <(find "${backup_dir}" -type f)
rm -rf "${backup_dir}"
}
restore_stale_backup_if_needed() {
if [[ ! -f "${MARKER_FILE}" ]]; then
return 0
fi
echo "[obf-pre] Detected stale marker from previous build, restoring workspace first..."
# shellcheck disable=SC1090
source "${MARKER_FILE}"
if [[ -n "${BACKUP_DIR:-}" && -n "${ROOT:-}" ]]; then
restore_from_backup_dir "${BACKUP_DIR}"
fi
rm -f "${MARKER_FILE}"
}
restore_on_error() {
local exit_code=$?
if [[ ${PRE_SUCCESS} -eq 1 ]]; then
return 0
fi
echo "[obf-pre] Failed (exit=${exit_code}), restoring source files from backup..."
restore_from_backup_dir "${BACKUP_DIR}"
rm -f "${MARKER_FILE}"
echo "[obf-pre] Restore complete."
exit ${exit_code}
}
# Run repository self-check to ensure no residual obfuscated tokens remain.
trap restore_on_error EXIT INT TERM
cd "${ROOT}"
restore_stale_backup_if_needed
echo "[obf-pre] Running repository self-check"
# Self-check must run before creating backups to avoid restoring a pre-check backup on failure.
bash "${ROOT}/AutoPacking/obfuscation/scripts/obfuscation_self_check.sh" "${ROOT}"
echo "[obf-pre] Generating dylib whitelist with HMAC"
python3 "${ROOT}/AutoPacking/obfuscation/generate_image_whitelist.py"
mkdir -p "${BACKUP_DIR}" "${MAP_DIR}"
echo "[obf-pre] Backing up source files to ${BACKUP_DIR}"
# Backup all source files that may be touched by obfuscation. Keep this for rollback safety.
while IFS= read -r file; do
rel="${file#./}"
mkdir -p "${BACKUP_DIR}/$(dirname "$rel")"
cp "$file" "${BACKUP_DIR}/$rel"
done < <(
find . \
\( -path './Pods' -o -path './Pods/*' -o -path './.git' -o -path './.git/*' -o -path './build_output' -o -path './build_output/*' -o -path './DerivedData' -o -path './DerivedData/*' -o -path './readoorTests' -o -path './readoorTests/*' -o -name '*.framework' -o -name '*.xcframework' \) -prune -o \
-type f \( -name '*.swift' -o -name '*.m' -o -name '*.mm' -o -name '*.h' -o -name '*.pch' \) -print
)
echo "[obf-pre] Running obfuscation in archive mode (auto apply + type/method maps)..."
OBF_TIMESTAMP="${STAMP}" python3 "${ROOT}/AutoPacking/obfuscation/obfuscate_symbols.py" --archive-mode
echo "BACKUP_DIR=${BACKUP_DIR}" > "${MARKER_FILE}"
echo "ROOT=${ROOT}" >> "${MARKER_FILE}"
PRE_SUCCESS=1
trap - EXIT INT TERM
echo "[obf-pre] Done. map=${MAP_PATH} method_map=${METHOD_MAP_PATH} marker=${MARKER_FILE}"
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
# Scan repository for residual obfuscated tokens like RdXXXXXX and fail if any found.
# Exits 0 when clean, non-zero when tokens are present.
ROOT="${1:-$(cd "$(dirname "$0")/../../.." && pwd)}"
echo "[obf-check] Scanning for residual obfuscated tokens in ${ROOT}"
EXCLUDE=("./Pods" "./.git" "./obfuscation_maps" "./build_output" "./DerivedData" "./readoorTests")
GREP_EXCLUDE_ARGS=()
for e in "${EXCLUDE[@]}"; do
GREP_EXCLUDE_ARGS+=(--exclude-dir "${e#./}")
done
FOUND=0
TOKEN_SUFFIX_RE='(?:[a-f0-9]{6}|[a-f0-9]{8}|[a-f0-9]{10})'
echo "[obf-check] Looking for token pattern '\b(?:Rd|YMH)${TOKEN_SUFFIX_RE}\b' in source files..."
if grep -R --line-number -E "\\b(?:Rd|YMH)${TOKEN_SUFFIX_RE}\\b" --include='*.swift' --include='*.m' --include='*.mm' --include='*.h' "${ROOT}" "${GREP_EXCLUDE_ARGS[@]}"; then
FOUND=1
fi
echo "[obf-check] Looking for obfuscated header imports like \"(Rd|YMH)XXXXXX.h\"..."
if grep -R --line-number -E "#import\\s+\"(?:Rd|YMH)${TOKEN_SUFFIX_RE}\\.h\"" --include='*.pch' --include='*.h' --include='*.m' --include='*.mm' "${ROOT}" "${GREP_EXCLUDE_ARGS[@]}"; then
FOUND=1
fi
if [[ ${FOUND} -ne 0 ]]; then
echo "[obf-check] ERROR: Found residual obfuscated tokens; aborting to avoid double-obfuscation."
echo "[obf-check] Run: python3 AutoPacking/obfuscation/obfuscate_symbols.py --restore-source-from-map-dir obfuscation_maps or run the auto-restore tool before archiving."
exit 2
fi
echo "[obf-check] Clean: no residual obfuscated tokens found."
exit 0
@@ -0,0 +1,21 @@
# BEGIN AUTO-DETECTED-OBJC-DOT-SYNTAX
# Auto-generated: Obj-C zero-arg methods that are referenced with dot syntax.
# These names are excluded from direct-method obfuscation to avoid half-rewritten call sites.
XMLString
imageLayer
isSquareCover
middleX
middleY
needsUpdateFont
observingKeys
orgidStr
placeholderTextView
refreshUI
removeAudioView
rootElement
searchHotkeyBlock
showRhythmicRedivisionView
socket4FD
socket6FD
vocabularyCount
# END AUTO-DETECTED-OBJC-DOT-SYNTAX
@@ -0,0 +1,120 @@
{
"images": [
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "68x68",
"idiom": "universal",
"filename": "icon-68@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "76x76",
"idiom": "universal",
"filename": "icon-76@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "83.5x83.5",
"idiom": "universal",
"filename": "icon-83.5@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "1024x1024",
"idiom": "universal",
"filename": "icon-1024.png",
"scale": "1x",
"platform": "ios"
}
],
"info": {
"version": 1,
"author": "icon.wuruihong.com"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 280 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "828_1792.png",
"idiom" : "iphone",
"scale" : "2x"
},
{
"filename" : "1242_2688.png",
"idiom" : "iphone",
"scale" : "3x"
},
{
"filename" : "2048_2732.png",
"idiom" : "ipad",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,120 @@
{
"images": [
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "68x68",
"idiom": "universal",
"filename": "icon-68@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "76x76",
"idiom": "universal",
"filename": "icon-76@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "83.5x83.5",
"idiom": "universal",
"filename": "icon-83.5@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "1024x1024",
"idiom": "universal",
"filename": "icon-1024.png",
"scale": "1x",
"platform": "ios"
}
],
"info": {
"version": 1,
"author": "icon.wuruihong.com"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "828_1792.png",
"idiom" : "iphone",
"scale" : "2x"
},
{
"filename" : "1242_2688.png",
"idiom" : "iphone",
"scale" : "3x"
},
{
"filename" : "2048_2732.png",
"idiom" : "ipad",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,120 @@
{
"images": [
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "68x68",
"idiom": "universal",
"filename": "icon-68@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "76x76",
"idiom": "universal",
"filename": "icon-76@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "83.5x83.5",
"idiom": "universal",
"filename": "icon-83.5@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "1024x1024",
"idiom": "universal",
"filename": "icon-1024.png",
"scale": "1x",
"platform": "ios"
}
],
"info": {
"version": 1,
"author": "icon.wuruihong.com"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "828_1792.png",
"idiom" : "iphone",
"scale" : "2x"
},
{
"filename" : "1242_2688.png",
"idiom" : "iphone",
"scale" : "3x"
},
{
"filename" : "2048_2732.png",
"idiom" : "ipad",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,120 @@
{
"images": [
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "68x68",
"idiom": "universal",
"filename": "icon-68@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "76x76",
"idiom": "universal",
"filename": "icon-76@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "83.5x83.5",
"idiom": "universal",
"filename": "icon-83.5@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "1024x1024",
"idiom": "universal",
"filename": "icon-1024.png",
"scale": "1x",
"platform": "ios"
}
],
"info": {
"version": 1,
"author": "icon.wuruihong.com"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,28 @@
-----BEGIN ALI VIDEO CERT-----
LmlsQS5jaUwAAWRjY2FhYTU1OTAxNzE0NDg2OWJlOGJkNzczZTc5ZjAzM2YwMDhiO
WY5ODUwNTc5MGY4NDEyOTRiMzNjMmZiYjg1NGUxZThlNjAwNzRmNTgxZTU0MDY2Mj
RiMGFiM2JkNWVmOTE3ZDE0MDJlNDQ0ZDUwNmExMTc5ZjFlYjYyODQ2MDRiYzdiYjA
zOTI2ZjYyOTE1OWVjYjY0ZDk1ZDhhN2Q5ZWZlOTRjOTdkMTA0Yzc1MWU5OGEwNGY3
OTdkOTdiYTAzMWEyMDFlMzc2Mjg0NGI1MTEyYWRkZDQzNTgyZDA2NTJkYTk0MDk1Y
TIxYjY2NWJmYjY0ZTkwMWQyZGNjNmVjMjY1ODFkYmJjZjBhY2ZmZjFhNDU3NTJjND
QwNTMzZmJlZDk3YTIzMmMyOWQ4YWVkZDY0YmZhZDc2ZDY1MTRiZGU5MmM3YTdjMGZ
iYjI3OTNhNjZiMDEzMjBkODgxYzBhMjA1YTExN2Q1YmU3ZTgyMzYyYzI5MDJhZDNk
MmFiZTcxNTQ5NzI2YzI4NTVjOTc5MWVmZDVjMGU4NDZmMDZlZDhlYTY1MjgzYWUzM
2JkOTYzOWJiZTkwOGU4ZTBmY2QwNDc1OWQ5N2U3OTk1NDNhMzdkNDQ2YzZlMDJhMj
BkODI2YTUzZGIzNTNjNWU3MDVjYmJmYzg5ODVjYjRkMWIxAAAB4y7ljgAlrpr0vwG
geEX9x69jHpLId+kMOlcrZQfEF6SUS+ACr/SK46qq7Eoy08BaH0DPOY5wioF3TFXt
PYkIzbs6nOK1dV2UJcB7FNusUrrNngoWw2qEateHRVXCjNBFW0UM5SNVWT9lriH3/
IntvmpfVlr5fspmZmJ71PUDk1d7zCj2Lk4Y/B3WXndJ5XCfx82miYVbaTlS+ZOKDO
0npHl22hqbxLSDN5a0RqKfj12MlUiNdGbRhuUA/NasXCulcPuvkEje47zW6o+Qtvb
uBmP8+5gXO3ZMi0Le3TRue3dApzVtCe1lXr6Xrt1+jCNijIhBRgHgXfPZ3HSfZNuz
of7ks1FEAAAAxH5P9HMl2FvUtxV4KPcco1PxKYr0+29gpvTMAhZ2PxDUlnUEmdRNN
tD3plAyk+D91IbXC8w5Fq0WZy8iE8twCheABJRuwUqDeJKQbgEkmhr98HTq2877wX
mkZAoPFjHwlIEOdf2Sih3SraFH0GzfYExG4ZItOTigyXOM5BjZPg8At1soyU1ldNx
CWdL7Zn5OWfkkUPsXLvKyYMYpunRpei46nJMIQ9aICDokMwZfCpkU83pVP30bjea+
E6keAf6K5x6Nfn7kw2Njo8kNT4ZUDz1vpCZR0OZjSeUplLPK357JCVwxactLVcT8p
1VJFvvzEcNdBmxQ37/MkzWAWJ7NEf4AAAGZsb2HiAAAAGoAAAAgZzE1MDYyZTVjNz
EyNDgxMWFkN2E3Njg1MjJhYWM1NGQAAAACAAAAHQAAAAAAAAACAAAAEWNvbS5lY25
1cC5hYVMxNmQzAAAAHQAAAAAAAAABAAAAEWNvbS5lY251cC5hYVMxNmQzAAAAAgAA
ACUAAAACAAAAAAAAAAEAACPxAAABoTX9/AAAAAAACAAAAAEAAAAAAAAAJQAAAAIAA
AAAAAAAAgAAI/EAAAGhNf38AAAAAAAIAAAAAQAAAAA=
-----END ALI VIDEO CERT-----
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "828_1792.png",
"idiom" : "iphone",
"scale" : "2x"
},
{
"filename" : "1242_2688.png",
"idiom" : "iphone",
"scale" : "3x"
},
{
"filename" : "2048_2732.png",
"idiom" : "ipad",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,120 @@
{
"images": [
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "20x20",
"idiom": "universal",
"filename": "icon-20@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "29x29",
"idiom": "universal",
"filename": "icon-29@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "38x38",
"idiom": "universal",
"filename": "icon-38@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "40x40",
"idiom": "universal",
"filename": "icon-40@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "60x60",
"idiom": "universal",
"filename": "icon-60@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "64x64",
"idiom": "universal",
"filename": "icon-64@3x.png",
"scale": "3x",
"platform": "ios"
},
{
"size": "68x68",
"idiom": "universal",
"filename": "icon-68@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "76x76",
"idiom": "universal",
"filename": "icon-76@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "83.5x83.5",
"idiom": "universal",
"filename": "icon-83.5@2x.png",
"scale": "2x",
"platform": "ios"
},
{
"size": "1024x1024",
"idiom": "universal",
"filename": "icon-1024.png",
"scale": "1x",
"platform": "ios"
}
],
"info": {
"version": 1,
"author": "icon.wuruihong.com"
}
}

Some files were not shown because too many files have changed in this diff Show More