Add iOS App Store build automation (#3451)

This commit is contained in:
Steven Enamakel
2026-06-06 18:03:07 -04:00
committed by GitHub
parent f60def53a4
commit c19b68a94b
31 changed files with 2262 additions and 166 deletions
+476
View File
@@ -0,0 +1,476 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const requireFromApp = createRequire(path.join(rootDir, "app/package.json"));
const { chromium } = requireFromApp("@playwright/test");
const outDir = path.join(rootDir, "fastlane/screenshots/en-US");
const width = 1320;
const height = 2868;
async function pngDataUri(relativePath) {
const buffer = await readFile(path.join(rootDir, relativePath));
return `data:image/png;base64,${buffer.toString("base64")}`;
}
function escapeHtml(value) {
return value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function shell(content) {
return `
<div class="phone">
<div class="status"><span>9:41</span><span>5G</span></div>
${content}
<div class="home"></div>
</div>
`;
}
function pairScreen() {
return shell(`
<section class="pair">
<div class="qr-mark">
<div></div><div></div><div></div><span></span>
</div>
<h2>Pair with your desktop</h2>
<p>Scan the QR code from OpenHuman on your computer and connect your phone in seconds.</p>
<button>Scan QR code</button>
<ol>
<li>Open OpenHuman on desktop</li>
<li>Go to Settings > Devices</li>
<li>Tap Pair phone to show QR</li>
</ol>
</section>
`);
}
function chatScreen() {
return shell(`
<section class="chat">
<header>
<small>Connected to</small>
<strong>Desktop</strong>
</header>
<div class="avatar">
<div class="face">
<span></span><span></span><i></i>
</div>
</div>
<div class="messages">
<p class="assistant">I found the latest thread context from your desktop workspace.</p>
<p class="user">Summarize what needs my attention.</p>
<p class="assistant">You have two follow-ups, one meeting note, and a draft ready to send.</p>
</div>
<footer>
<div class="mic"></div>
<div class="input">Type a message...</div>
<div class="send"></div>
</footer>
</section>
`);
}
function privacyScreen() {
return shell(`
<section class="privacy">
<div class="lock">
<span></span>
</div>
<h2>Anchored to your desktop</h2>
<p>Your phone is a companion surface. Memory, tools, and integrations stay with the OpenHuman runtime you paired.</p>
<div class="rows">
<div><strong>Short-lived QR pairing</strong><span>Connect intentionally</span></div>
<div><strong>Push-to-talk voice</strong><span>Speak when you choose</span></div>
<div><strong>Desktop-owned context</strong><span>Use the assistant you already set up</span></div>
</div>
</section>
`);
}
function html({ title, kicker, body, iconUri, wordmarkUri }) {
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
* { box-sizing: border-box; }
html, body { margin: 0; width: ${width}px; height: ${height}px; overflow: hidden; }
body {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #10131a;
color: white;
}
.shot {
position: relative;
width: ${width}px;
height: ${height}px;
padding: 122px 92px 108px;
background:
radial-gradient(circle at 20% 14%, rgba(74, 131, 221, 0.42), transparent 34%),
linear-gradient(160deg, #121720 0%, #111827 48%, #18231f 100%);
}
.brand { display: flex; align-items: center; gap: 22px; height: 82px; }
.brand img.icon { width: 82px; height: 82px; border-radius: 22px; }
.brand img.wordmark { width: 330px; height: auto; }
.copy { margin-top: 104px; width: 100%; }
.kicker {
color: #9abff9;
font-size: 34px;
font-weight: 700;
letter-spacing: 0;
margin-bottom: 26px;
}
h1 {
font-family: "Cabinet Grotesk", Inter, -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 86px;
line-height: 0.96;
letter-spacing: 0;
margin: 0;
max-width: 1000px;
}
.subtitle {
margin-top: 34px;
color: rgba(255, 255, 255, 0.76);
font-size: 38px;
line-height: 1.3;
max-width: 930px;
}
.phone {
position: absolute;
left: 50%;
bottom: 94px;
transform: translateX(-50%);
width: 780px;
height: 1510px;
border-radius: 88px;
background: #0f1117;
border: 18px solid #202735;
box-shadow: 0 52px 120px rgba(0, 0, 0, 0.48);
overflow: hidden;
}
.status {
height: 78px;
padding: 24px 54px 0;
display: flex;
justify-content: space-between;
font-size: 24px;
color: rgba(255, 255, 255, 0.76);
}
.home {
position: absolute;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
width: 210px;
height: 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.42);
}
.pair, .privacy {
min-height: calc(100% - 78px);
padding: 230px 58px 110px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.qr-mark {
width: 154px;
height: 154px;
border-radius: 34px;
background: #4a83dd;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 28px;
box-shadow: 0 22px 54px rgba(74, 131, 221, 0.36);
}
.qr-mark div, .qr-mark span {
background: white;
border-radius: 12px;
opacity: 0.92;
}
.pair h2, .privacy h2 {
font-size: 48px;
line-height: 1.06;
margin: 54px 0 20px;
letter-spacing: 0;
}
.pair p, .privacy p {
margin: 0;
font-size: 27px;
line-height: 1.42;
color: rgba(255, 255, 255, 0.62);
}
.pair button {
margin-top: 70px;
width: 100%;
height: 96px;
border: 0;
border-radius: 26px;
background: #4a83dd;
color: white;
font-size: 30px;
font-weight: 700;
}
.pair ol {
margin: 78px 0 0;
padding: 0;
list-style: none;
width: 100%;
text-align: left;
display: grid;
gap: 22px;
}
.pair li {
padding: 26px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.78);
font-size: 25px;
}
.chat header {
height: 112px;
padding: 16px 34px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
justify-content: center;
}
.chat small {
color: rgba(255, 255, 255, 0.42);
font-size: 20px;
text-transform: uppercase;
}
.chat strong { margin-top: 8px; font-size: 28px; }
.avatar {
height: 500px;
display: flex;
align-items: center;
justify-content: center;
}
.face {
width: 330px;
height: 330px;
border-radius: 50%;
background: linear-gradient(145deg, #8bb4f4, #4a83dd 58%, #6cbf9a);
position: relative;
box-shadow: 0 26px 76px rgba(74, 131, 221, 0.42);
}
.face span {
position: absolute;
top: 120px;
width: 38px;
height: 48px;
border-radius: 50%;
background: #10131a;
}
.face span:first-child { left: 98px; }
.face span:nth-child(2) { right: 98px; }
.face i {
position: absolute;
left: 50%;
bottom: 86px;
transform: translateX(-50%);
width: 108px;
height: 34px;
border-radius: 0 0 80px 80px;
border-bottom: 16px solid #10131a;
}
.messages {
padding: 0 34px;
display: grid;
gap: 20px;
}
.messages p {
margin: 0;
padding: 24px 26px;
border-radius: 28px;
font-size: 25px;
line-height: 1.32;
}
.messages .assistant {
background: rgba(255, 255, 255, 0.09);
color: rgba(255, 255, 255, 0.82);
justify-self: start;
max-width: 570px;
}
.messages .user {
background: #4a83dd;
justify-self: end;
max-width: 520px;
}
.chat footer {
position: absolute;
left: 0;
right: 0;
bottom: 54px;
height: 116px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
padding: 22px 30px;
display: flex;
align-items: center;
gap: 18px;
}
.mic, .send {
width: 70px;
height: 70px;
border-radius: 22px;
background: #4a83dd;
}
.mic::before {
content: "";
display: block;
width: 22px;
height: 34px;
margin: 16px auto 0;
border-radius: 999px;
border: 5px solid white;
}
.send::before {
content: "";
display: block;
width: 24px;
height: 24px;
margin: 23px auto 0;
border-top: 6px solid white;
border-right: 6px solid white;
transform: rotate(45deg);
}
.input {
flex: 1;
height: 70px;
border-radius: 22px;
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.36);
font-size: 24px;
display: flex;
align-items: center;
padding: 0 24px;
}
.lock {
width: 164px;
height: 164px;
border-radius: 40px;
background: #6cbf9a;
position: relative;
box-shadow: 0 22px 54px rgba(108, 191, 154, 0.3);
}
.lock::before {
content: "";
position: absolute;
left: 46px;
top: 36px;
width: 72px;
height: 62px;
border: 14px solid white;
border-bottom: 0;
border-radius: 42px 42px 0 0;
}
.lock span {
position: absolute;
left: 38px;
bottom: 34px;
width: 88px;
height: 70px;
border-radius: 16px;
background: white;
}
.rows {
margin-top: 78px;
width: 100%;
display: grid;
gap: 20px;
}
.rows div {
text-align: left;
padding: 26px 28px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.08);
}
.rows strong {
display: block;
font-size: 27px;
margin-bottom: 8px;
}
.rows span {
color: rgba(255, 255, 255, 0.56);
font-size: 23px;
}
</style>
</head>
<body>
<main class="shot">
<div class="brand">
<img class="icon" src="${iconUri}" alt="">
<img class="wordmark" src="${wordmarkUri}" alt="">
</div>
<section class="copy">
<div class="kicker">${escapeHtml(kicker)}</div>
<h1>${escapeHtml(title)}</h1>
<p class="subtitle">${escapeHtml(body)}</p>
</section>
${body.includes("QR") ? pairScreen() : title.includes("voice") ? chatScreen() : privacyScreen()}
</main>
</body>
</html>`;
}
const shots = [
{
file: "iPhone_6_9_01_pair_with_desktop.png",
kicker: "OpenHuman for iPhone",
title: "Pair with your desktop",
body: "Scan the QR code from OpenHuman on your computer and connect your phone to the assistant you already trust.",
},
{
file: "iPhone_6_9_02_chat_and_voice.png",
kicker: "Text and push-to-talk",
title: "Chat by text or voice",
body: "Send quick messages, dictate thoughts, and get spoken replies while your desktop runtime does the work.",
},
{
file: "iPhone_6_9_03_desktop_anchored.png",
kicker: "Desktop-owned context",
title: "Your memory stays anchored",
body: "Use your paired OpenHuman setup for memory, tools, and integrations without turning the phone into a separate workspace.",
},
];
await mkdir(outDir, { recursive: true });
const iconUri = await pngDataUri(
"app/src-tauri-mobile/icons/store/appstore.png",
);
const wordmarkUri = await pngDataUri(
"app/public/brand/OpenhumanLogo+wordmark-White.png",
);
const browser = await chromium.launch();
try {
const page = await browser.newPage({
viewport: { width, height },
deviceScaleFactor: 1,
});
for (const shot of shots) {
await page.setContent(html({ ...shot, iconUri, wordmarkUri }), {
waitUntil: "load",
});
const destination = path.join(outDir, shot.file);
await page.screenshot({ path: destination, fullPage: false });
console.log(
`[ios-appstore-assets] wrote ${path.relative(rootDir, destination)}`,
);
}
} finally {
await browser.close();
}
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env node
import crypto from "node:crypto";
import { readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, "..");
const metadataDir = path.join(rootDir, "fastlane/metadata/en-US");
const screenshotDir = path.join(rootDir, "fastlane/screenshots/en-US");
const apiBase = "https://api.appstoreconnect.apple.com/v1";
const appId = process.env.ASC_APP_ID || "6761229174";
const locale = process.env.ASC_LOCALE || "en-US";
const platform = process.env.ASC_PLATFORM || "IOS";
const screenshotDisplayType =
process.env.ASC_SCREENSHOT_DISPLAY_TYPE || "APP_IPHONE_67";
const versionString =
process.env.ASC_VERSION_STRING ||
JSON.parse(await readFile(path.join(rootDir, "app/package.json"), "utf8"))
.version;
const keyId = process.env.ASC_KEY_ID;
const issuerId = process.env.ASC_ISSUER_ID;
const keyPath = process.env.ASC_KEY_PATH;
if (!keyId || !issuerId || !keyPath) {
throw new Error("ASC_KEY_ID, ASC_ISSUER_ID, and ASC_KEY_PATH are required.");
}
function base64Url(input) {
return Buffer.from(input)
.toString("base64")
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
async function createJwt() {
const privateKey = await readFile(keyPath, "utf8");
const now = Math.floor(Date.now() / 1000);
const header = { alg: "ES256", kid: keyId, typ: "JWT" };
const payload = {
iss: issuerId,
aud: "appstoreconnect-v1",
iat: now,
exp: now + 15 * 60,
};
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(payload))}`;
const signature = crypto.sign("sha256", Buffer.from(signingInput), {
key: privateKey,
dsaEncoding: "ieee-p1363",
});
return `${signingInput}.${base64Url(signature)}`;
}
const jwt = await createJwt();
async function request(
method,
resourcePath,
body,
{ raw = false, headers = {} } = {},
) {
const res = await fetch(`${apiBase}${resourcePath}`, {
method,
headers: {
Authorization: `Bearer ${jwt}`,
Accept: "application/json",
...(body && !raw ? { "Content-Type": "application/json" } : {}),
...headers,
},
body: body ? (raw ? body : JSON.stringify(body)) : undefined,
});
const text = await res.text();
const payload = text ? JSON.parse(text) : null;
if (!res.ok) {
const message =
payload?.errors
?.map((e) => `${e.status} ${e.code}: ${e.detail}`)
.join("\n") || text;
throw new Error(`${method} ${resourcePath} failed: ${message}`);
}
return payload;
}
async function uploadOperation(operation, fileBuffer) {
const headers = Object.fromEntries(
(operation.requestHeaders || []).map((h) => [h.name, h.value]),
);
const offset = Number(operation.offset || 0);
const length = Number(operation.length || fileBuffer.length);
const chunk = fileBuffer.subarray(offset, offset + length);
const res = await fetch(operation.url, {
method: operation.method,
headers,
body: chunk,
});
if (!res.ok) {
throw new Error(
`asset upload failed: ${res.status} ${res.statusText} ${await res.text()}`,
);
}
}
async function textFile(name) {
return (await readFile(path.join(metadataDir, name), "utf8")).trim();
}
async function firstPage(resourcePath) {
const payload = await request("GET", resourcePath);
return payload.data || [];
}
async function getOrCreateAppInfoLocalization() {
const appInfos = await firstPage(`/apps/${appId}/appInfos?limit=10`);
if (!appInfos.length) {
throw new Error(`No appInfos found for app ${appId}.`);
}
const appInfo = appInfos[0];
const existing = await firstPage(
`/appInfos/${appInfo.id}/appInfoLocalizations?limit=50`,
);
const match = existing.find((item) => item.attributes?.locale === locale);
if (match) return match;
const created = await request("POST", "/appInfoLocalizations", {
data: {
type: "appInfoLocalizations",
attributes: { locale, name: await textFile("name.txt") },
relationships: {
appInfo: { data: { type: "appInfos", id: appInfo.id } },
},
},
});
return created.data;
}
async function updateAppInfoLocalization() {
const localization = await getOrCreateAppInfoLocalization();
await request("PATCH", `/appInfoLocalizations/${localization.id}`, {
data: {
type: "appInfoLocalizations",
id: localization.id,
attributes: {
name: await textFile("name.txt"),
subtitle: await textFile("subtitle.txt"),
privacyPolicyUrl: await textFile("privacy_url.txt"),
},
},
});
console.log(
`[ios-appstore-metadata] updated app info localization ${locale}`,
);
}
async function getOrCreateAppStoreVersion() {
const versions = await firstPage(
`/apps/${appId}/appStoreVersions?filter[platform]=${platform}&limit=20`,
);
const editableStates = new Set([
"PREPARE_FOR_SUBMISSION",
"DEVELOPER_REJECTED",
"REJECTED",
"METADATA_REJECTED",
"INVALID_BINARY",
]);
const existing =
versions.find((v) => v.attributes?.versionString === versionString) ||
versions.find((v) => editableStates.has(v.attributes?.appStoreState));
if (existing) return existing;
const created = await request("POST", "/appStoreVersions", {
data: {
type: "appStoreVersions",
attributes: {
platform,
versionString,
copyright: await textFile("copyright.txt"),
},
relationships: {
app: { data: { type: "apps", id: appId } },
},
},
});
console.log(
`[ios-appstore-metadata] created ${platform} version ${versionString}`,
);
return created.data;
}
async function getOrCreateVersionLocalization(version) {
const existing = await firstPage(
`/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`,
);
const match = existing.find((item) => item.attributes?.locale === locale);
if (match) return match;
const created = await request("POST", "/appStoreVersionLocalizations", {
data: {
type: "appStoreVersionLocalizations",
attributes: { locale },
relationships: {
appStoreVersion: { data: { type: "appStoreVersions", id: version.id } },
},
},
});
return created.data;
}
async function updateVersionLocalization() {
const version = await getOrCreateAppStoreVersion();
const localization = await getOrCreateVersionLocalization(version);
await request("PATCH", `/appStoreVersionLocalizations/${localization.id}`, {
data: {
type: "appStoreVersionLocalizations",
id: localization.id,
attributes: {
description: await textFile("description.txt"),
keywords: await textFile("keywords.txt"),
marketingUrl: await textFile("marketing_url.txt"),
promotionalText: await textFile("promotional_text.txt"),
supportUrl: await textFile("support_url.txt"),
},
},
});
console.log(
`[ios-appstore-metadata] updated version localization ${locale} for ${version.attributes.versionString}`,
);
return localization;
}
async function deleteExistingScreenshotSet(localization) {
const sets = await firstPage(
`/appStoreVersionLocalizations/${localization.id}/appScreenshotSets?filter[screenshotDisplayType]=${screenshotDisplayType}&limit=50&include=appScreenshots`,
);
for (const set of sets) {
await request("DELETE", `/appScreenshotSets/${set.id}`);
console.log(
`[ios-appstore-metadata] deleted existing screenshot set ${set.id}`,
);
}
}
async function createScreenshotSet(localization) {
const created = await request("POST", "/appScreenshotSets", {
data: {
type: "appScreenshotSets",
attributes: { screenshotDisplayType },
relationships: {
appStoreVersionLocalization: {
data: { type: "appStoreVersionLocalizations", id: localization.id },
},
},
},
});
console.log(
`[ios-appstore-metadata] created screenshot set ${screenshotDisplayType}`,
);
return created.data;
}
async function uploadScreenshot(set, filePath) {
const fileName = path.basename(filePath);
const fileBuffer = await readFile(filePath);
const fileInfo = await stat(filePath);
const checksum = crypto.createHash("md5").update(fileBuffer).digest("hex");
const reservation = await request("POST", "/appScreenshots", {
data: {
type: "appScreenshots",
attributes: { fileName, fileSize: fileInfo.size },
relationships: {
appScreenshotSet: { data: { type: "appScreenshotSets", id: set.id } },
},
},
});
const operations = reservation.data.attributes.uploadOperations || [];
for (const operation of operations) {
await uploadOperation(operation, fileBuffer);
}
await request("PATCH", `/appScreenshots/${reservation.data.id}`, {
data: {
type: "appScreenshots",
id: reservation.data.id,
attributes: {
uploaded: true,
sourceFileChecksum: checksum,
},
},
});
console.log(`[ios-appstore-metadata] uploaded ${fileName}`);
}
async function uploadScreenshots(localization) {
const files = (await readdir(screenshotDir))
.filter((file) => file.endsWith(".png"))
.sort()
.map((file) => path.join(screenshotDir, file));
if (!files.length) {
throw new Error(`No screenshots found in ${screenshotDir}`);
}
await deleteExistingScreenshotSet(localization);
const set = await createScreenshotSet(localization);
for (const file of files) {
await uploadScreenshot(set, file);
}
}
await updateAppInfoLocalization();
const versionLocalization = await updateVersionLocalization();
await uploadScreenshots(versionLocalization);
console.log(
"[ios-appstore-metadata] metadata and screenshots submitted to App Store Connect.",
);
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# Build, export, and optionally upload the OpenHuman iOS IPA to App Store Connect.
#
# Required local inputs:
# TEAM_ID=XXXXXXXXXX
# IOS_APPSTORE_PROVISIONING_PROFILE_PATH=/path/to/profile.mobileprovision
#
# Required only when UPLOAD=1:
# ASC_KEY_ID=XXXXXXXXXX
# ASC_ISSUER_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
# ASC_KEY_PATH=/path/to/AuthKey_XXXXXXXXXX.p8
#
# Optional:
# BUILD_NUMBER=123
# UPLOAD=0|1 (default: 0)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
APP_IDENTIFIER="com.tinyhumansai.openhuman"
MOBILE_DIR="$REPO_ROOT/app/src-tauri-mobile"
APPLE_DIR="$MOBILE_DIR/gen/apple"
ARCHIVE_PATH="$APPLE_DIR/build/openhuman-mobile_iOS.xcarchive"
EXPORT_DIR="$APPLE_DIR/build/appstore-export"
PROFILE_PATH="${IOS_APPSTORE_PROVISIONING_PROFILE_PATH:-}"
TEAM_ID="${TEAM_ID:-${APPLE_DEVELOPMENT_TEAM:-}}"
UPLOAD="${UPLOAD:-0}"
BUILD_NUMBER="${BUILD_NUMBER:-$(date -u +%Y%m%d%H%M)}"
MARKETING_VERSION="$(node -p "require('./app/src-tauri-mobile/tauri.conf.json').version")"
die() {
echo "[ios-appstore] ERROR: $*" >&2
exit 1
}
[[ -n "$TEAM_ID" ]] || die "TEAM_ID is required"
[[ -n "$PROFILE_PATH" ]] || die "IOS_APPSTORE_PROVISIONING_PROFILE_PATH is required"
[[ -f "$PROFILE_PATH" ]] || die "provisioning profile not found: $PROFILE_PATH"
if [[ "$UPLOAD" == "1" ]]; then
[[ -n "${ASC_KEY_ID:-}" ]] || die "ASC_KEY_ID is required when UPLOAD=1"
[[ -n "${ASC_ISSUER_ID:-}" ]] || die "ASC_ISSUER_ID is required when UPLOAD=1"
[[ -n "${ASC_KEY_PATH:-}" ]] || die "ASC_KEY_PATH is required when UPLOAD=1"
[[ -f "$ASC_KEY_PATH" ]] || die "App Store Connect key not found: $ASC_KEY_PATH"
fi
PROFILE_PLIST="$(mktemp -t openhuman-appstore-profile.XXXXXX.plist)"
security cms -D -i "$PROFILE_PATH" > "$PROFILE_PLIST"
PROFILE_UUID="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$PROFILE_PLIST")"
PROFILE_NAME="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$PROFILE_PLIST")"
PROFILE_APP_ID="$(/usr/libexec/PlistBuddy -c 'Print :Entitlements:application-identifier' "$PROFILE_PLIST")"
mkdir -p "$HOME/Library/MobileDevice/Provisioning Profiles"
cp "$PROFILE_PATH" "$HOME/Library/MobileDevice/Provisioning Profiles/$PROFILE_UUID.mobileprovision"
echo "[ios-appstore] team_id=$TEAM_ID"
echo "[ios-appstore] app_identifier=$APP_IDENTIFIER"
echo "[ios-appstore] profile_name=$PROFILE_NAME"
echo "[ios-appstore] profile_uuid=$PROFILE_UUID"
echo "[ios-appstore] profile_app_id=$PROFILE_APP_ID"
echo "[ios-appstore] version=$MARKETING_VERSION build=$BUILD_NUMBER"
echo "[ios-appstore] installed signing identities:"
security find-identity -v -p codesigning | sed 's/^/[ios-appstore] /'
echo "[ios-appstore] building web assets"
bash scripts/ci-cancel-aware.sh pnpm --filter openhuman-app run build:app
echo "[ios-appstore] generating iOS Xcode project"
TEAM_ID="$TEAM_ID" APPLE_DEVELOPMENT_TEAM="$TEAM_ID" bash scripts/ios-init.sh
mkdir -p "$APPLE_DIR/assets"
rsync -a --delete app/dist/ "$APPLE_DIR/assets/"
INFO_PLIST="$APPLE_DIR/openhuman-mobile_iOS/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $MARKETING_VERSION" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string $MARKETING_VERSION" "$INFO_PLIST"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :CFBundleVersion string $BUILD_NUMBER" "$INFO_PLIST"
echo "[ios-appstore] archiving iphoneos app"
xcodebuild \
-workspace "$APPLE_DIR/openhuman-mobile.xcodeproj/project.xcworkspace" \
-scheme openhuman-mobile_iOS \
-configuration release \
-sdk iphoneos \
-destination "generic/platform=iOS" \
-archivePath "$ARCHIVE_PATH" \
DEVELOPMENT_TEAM="$TEAM_ID" \
CODE_SIGN_STYLE=Manual \
CODE_SIGN_IDENTITY="Apple Distribution" \
PROVISIONING_PROFILE_SPECIFIER="$PROFILE_NAME" \
MARKETING_VERSION="$MARKETING_VERSION" \
CURRENT_PROJECT_VERSION="$BUILD_NUMBER" \
archive
EXPORT_OPTIONS="$(mktemp -t openhuman-export-options.XXXXXX.plist)"
mkdir -p "$EXPORT_DIR"
cat > "$EXPORT_OPTIONS" <<PLIST
<?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>method</key>
<string>app-store-connect</string>
<key>signingStyle</key>
<string>manual</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>provisioningProfiles</key>
<dict>
<key>$APP_IDENTIFIER</key>
<string>$PROFILE_NAME</string>
</dict>
<key>stripSwiftSymbols</key>
<true/>
<key>uploadSymbols</key>
<true/>
</dict>
</plist>
PLIST
echo "[ios-appstore] exporting IPA"
xcodebuild \
-exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_OPTIONS" \
-allowProvisioningUpdates
IPA_PATH="$(find "$EXPORT_DIR" -maxdepth 1 -name '*.ipa' -print -quit)"
[[ -n "$IPA_PATH" ]] || die "IPA export completed but no .ipa was found in $EXPORT_DIR"
echo "[ios-appstore] IPA ready: $IPA_PATH"
if [[ "$UPLOAD" != "1" ]]; then
echo "[ios-appstore] UPLOAD=0, stopping before App Store Connect upload."
exit 0
fi
ASC_KEY_DIR="$(mktemp -d -t openhuman-asc-keys.XXXXXX)"
cp "$ASC_KEY_PATH" "$ASC_KEY_DIR/AuthKey_${ASC_KEY_ID}.p8"
echo "[ios-appstore] uploading IPA to App Store Connect"
API_PRIVATE_KEYS_DIR="$ASC_KEY_DIR" \
xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--apiKey "$ASC_KEY_ID" \
--apiIssuer "$ASC_ISSUER_ID"
echo "[ios-appstore] upload submitted. Apple will process the build before it appears in App Store Connect."
+37 -2
View File
@@ -39,7 +39,12 @@ if [[ -z "$TEAM_ID" ]]; then
exit 1
fi
# Keep regeneration deterministic. Previous local builds can leave archives,
# copied libraries, and patched Xcode project state under gen/apple/.
rm -rf "$MOBILE_DIR/gen/apple"
npx --package=@tauri-apps/cli@^2 tauri ios init \
--ci \
-c "{\"bundle\":{\"iOS\":{\"developmentTeam\":\"$TEAM_ID\"}}}"
# Overwrite the placeholder AppIcon set Tauri generates with the real
@@ -58,14 +63,44 @@ fi
# barcode scanner (camera) is mandatory for QR pairing; mic + speech are
# needed by the PTT plugin. Without these, iOS will hard-crash the app on
# first use of each API.
INFO_PLIST=$(find "$MOBILE_DIR/gen/apple" -name "Info.plist" -path "*openhuman-mobile_iOS*" 2>/dev/null | head -1)
if [[ -n "$INFO_PLIST" ]]; then
INFO_PLIST="$MOBILE_DIR/gen/apple/openhuman-mobile_iOS/Info.plist"
if [[ -f "$INFO_PLIST" ]]; then
echo "[ios-init] injecting privacy keys → $INFO_PLIST"
/usr/libexec/PlistBuddy -c "Add :NSCameraUsageDescription string 'OpenHuman uses the camera to scan the pairing QR code from your desktop.'" "$INFO_PLIST" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :NSMicrophoneUsageDescription string 'OpenHuman uses the microphone for push-to-talk voice messages.'" "$INFO_PLIST" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :NSSpeechRecognitionUsageDescription string 'OpenHuman uses on-device speech recognition to transcribe your voice messages.'" "$INFO_PLIST" 2>/dev/null || true
fi
# The generated Xcode build phase runs `npm run -- tauri ...` from gen/apple.
# In this repo that resolves the app-level package script and makes Tauri look
# for app/src-tauri/gen/apple (desktop) instead of app/src-tauri-mobile/gen/apple.
# Tauri's `ios xcode-script` also loses access to the installed iOS simulator
# Rust std in Xcode's script environment. Build the mobile staticlib directly
# from the mobile crate root and copy it to the location the Xcode target links.
PROJECT_YML="$MOBILE_DIR/gen/apple/project.yml"
PBXPROJ="$MOBILE_DIR/gen/apple/openhuman-mobile.xcodeproj/project.pbxproj"
NEW_SCRIPT='cd ../.. && PATH=$HOME/.cargo/bin:$PATH RUSTUP_TOOLCHAIN=${RUSTUP_TOOLCHAIN:-1.93.0-aarch64-apple-darwin} IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET:-16.0} RUST_TARGET=aarch64-apple-ios && case "${SDK_NAME:-}" in iphonesimulator*) RUST_TARGET=aarch64-apple-ios-sim ;; esac && cargo build --package openhuman-mobile --manifest-path Cargo.toml --target "$RUST_TARGET" --features=tauri/custom-protocol --lib --release && mkdir -p "gen/apple/Externals/arm64/${CONFIGURATION:-release}" && cp "target/$RUST_TARGET/release/libopenhuman_mobile.a" "gen/apple/Externals/arm64/${CONFIGURATION:-release}/libapp.a"'
patch_xcode_script() {
local file="$1"
NEW_SCRIPT="$NEW_SCRIPT" perl -0pi -e 's#(- script: )[^\n]*(?:tauri ios xcode-script|cargo build --package openhuman-mobile)[^\n]*#$1$ENV{NEW_SCRIPT}#g' "$file"
NEW_SCRIPT="$NEW_SCRIPT" perl -pi -e '
if (/shellScript = / && /(tauri ios xcode-script|cargo build --package openhuman-mobile)/) {
my $script = $ENV{NEW_SCRIPT};
$script =~ s/\\/\\\\/g;
$script =~ s/"/\\"/g;
$_ = "\t\t\tshellScript = \"$script\";\n";
}
' "$file"
}
if [[ -f "$PROJECT_YML" ]]; then
echo "[ios-init] patching mobile Rust build phase → $PROJECT_YML"
patch_xcode_script "$PROJECT_YML"
fi
if [[ -f "$PBXPROJ" ]]; then
echo "[ios-init] patching mobile Rust build phase → $PBXPROJ"
patch_xcode_script "$PBXPROJ"
fi
echo ""
echo "[ios-init] Done. Next steps:"
echo ""