mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Add iOS App Store build automation (#3451)
This commit is contained in:
Executable
+476
@@ -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("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Executable
+319
@@ -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.",
|
||||
);
|
||||
Executable
+151
@@ -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
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user