diff --git a/builder/.env.example b/builder/.env.example new file mode 100644 index 0000000..31234de --- /dev/null +++ b/builder/.env.example @@ -0,0 +1,14 @@ +# Web UI password +ADMIN_PASSWORD=changeme + +# Session secret +SESSION_SECRET=changeme-random-string + +# Data directory (where SQLite, source archives, builds, profiles live) +DATA_DIR=/Users/m4mini/AppStoreBuilder/data + +# Port +PORT=3090 + +# Shared secret for enrollment callbacks from the unraid container +BUILDER_SHARED_SECRET=changeme-same-as-unraid-env diff --git a/builder/.gitignore b/builder/.gitignore new file mode 100644 index 0000000..fee1eb0 --- /dev/null +++ b/builder/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +.env +package-lock.json diff --git a/builder/bin/deploy.sh b/builder/bin/deploy.sh new file mode 100755 index 0000000..7f73d57 --- /dev/null +++ b/builder/bin/deploy.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Deploy builder source from the dev workspace to the runtime location on the Mac mini. +# Usage: ./bin/deploy.sh +set -euo pipefail + +DEV_DIR="$(cd "$(dirname "$0")/.." && pwd)" +RUN_DIR="/Users/m4mini/AppStoreBuilder/app" + +echo "Deploying builder from $DEV_DIR → $RUN_DIR" + +rsync -a \ + --exclude node_modules \ + --exclude data \ + --exclude .env \ + --exclude bin/deploy.sh \ + "$DEV_DIR/" "$RUN_DIR/" + +# Install/refresh deps if package.json changed +if ! cmp -s "$DEV_DIR/package.json" "$RUN_DIR/package.json.last" 2>/dev/null; then + (cd "$RUN_DIR" && npm install --production) + cp "$DEV_DIR/package.json" "$RUN_DIR/package.json.last" +fi + +# Restart the launchd service +UID_NUM=$(id -u) +if launchctl print "gui/$UID_NUM/com.88oak.appstorebuilder" >/dev/null 2>&1; then + launchctl kickstart -k "gui/$UID_NUM/com.88oak.appstorebuilder" + echo "Service kickstarted" +else + echo "Service not loaded — bootstrap it with:" + echo " launchctl bootstrap gui/$UID_NUM ~/Library/LaunchAgents/com.88oak.appstorebuilder.plist" +fi + +sleep 1 +curl -s http://localhost:3090/api/health && echo "" diff --git a/builder/fastlane/Appfile b/builder/fastlane/Appfile new file mode 100644 index 0000000..9517e6b --- /dev/null +++ b/builder/fastlane/Appfile @@ -0,0 +1 @@ +# App identifier and team id are passed per-lane-call, not hardcoded here. diff --git a/builder/fastlane/Fastfile b/builder/fastlane/Fastfile new file mode 100644 index 0000000..01a7c81 --- /dev/null +++ b/builder/fastlane/Fastfile @@ -0,0 +1,22 @@ +default_platform(:ios) + +platform :ios do + desc "Generate or refresh an ad-hoc provisioning profile for the given app identifier." + lane :generate_adhoc do |options| + app_identifier = options[:app_identifier] || ENV['APP_IDENTIFIER'] + output_path = options[:output_path] || ENV['OUTPUT_PATH'] || Dir.pwd + api_key_path = options[:api_key_path] || ENV['ASC_KEY_JSON'] + + UI.user_error!("app_identifier is required") unless app_identifier && !app_identifier.empty? + UI.user_error!("api_key_path is required") unless api_key_path && File.exist?(api_key_path) + + sigh( + adhoc: true, + force: true, + app_identifier: app_identifier, + api_key_path: api_key_path, + output_path: output_path, + skip_install: true, + ) + end +end diff --git a/builder/package.json b/builder/package.json new file mode 100644 index 0000000..71b1c0c --- /dev/null +++ b/builder/package.json @@ -0,0 +1,17 @@ +{ + "name": "ios-appstore-builder", + "version": "1.0.0", + "description": "Mac mini build service for the iOS App Store: ASC integration, fastlane profile management, server-side xcodebuild", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "dependencies": { + "better-sqlite3": "^11.7.0", + "express": "^4.21.0", + "express-session": "^1.18.1", + "multer": "^1.4.5-lts.1", + "uuid": "^10.0.0" + } +} diff --git a/builder/public/css/style.css b/builder/public/css/style.css new file mode 100644 index 0000000..2f2ef91 --- /dev/null +++ b/builder/public/css/style.css @@ -0,0 +1,511 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #0a0a0a; + --surface: #1a1a1a; + --surface-hover: #222; + --border: #333; + --text: #f5f5f5; + --text-muted: #888; + --accent: #007AFF; + --accent-hover: #0066d6; + --danger: #ff3b30; + --success: #30d158; + --radius: 12px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', system-ui, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +/* Header */ +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--border); + background: var(--surface); + position: sticky; + top: 0; + z-index: 10; +} + +header h1 { font-size: 20px; font-weight: 700; } + +nav { display: flex; gap: 8px; } + +nav a { + color: var(--text-muted); + text-decoration: none; + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + transition: all 0.15s; +} + +nav a:hover, nav a.active { + color: var(--text); + background: var(--bg); +} + +nav a.logout { color: var(--danger); } +nav a.logout:hover { background: rgba(255,59,48,0.1); } + +/* Main */ +main { max-width: 900px; margin: 0 auto; padding: 24px; } + +/* App Grid */ +.app-grid { display: flex; flex-direction: column; gap: 12px; } + +.app-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + transition: all 0.15s; +} + +.app-card:hover { background: var(--surface-hover); border-color: #444; } + +.app-icon { + width: 60px; + height: 60px; + border-radius: 14px; + background: #2a2a2a; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + flex-shrink: 0; + overflow: hidden; +} + +.app-icon img { width: 100%; height: 100%; object-fit: cover; } + +.app-info { flex: 1; min-width: 0; } +.app-name { font-weight: 600; font-size: 16px; } +.app-bundle { color: var(--text-muted); font-size: 13px; margin-top: 2px; } +.app-version { color: var(--text-muted); font-size: 13px; margin-top: 4px; } + +.app-action { flex-shrink: 0; } + +.btn, button[type="submit"] { + background: var(--accent); + color: white; + border: none; + padding: 10px 24px; + border-radius: 20px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; + text-decoration: none; + display: inline-block; +} + +.btn:hover, button[type="submit"]:hover { background: var(--accent-hover); } +.btn-sm { padding: 6px 16px; font-size: 13px; } +.btn-danger { background: var(--danger); } +.btn-danger:hover { background: #e0342a; } + +.install-btn { + background: var(--accent); + color: white; + border: none; + padding: 8px 20px; + border-radius: 20px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} + +/* Empty state */ +.empty-state { + text-align: center; + padding: 80px 20px; + color: var(--text-muted); +} + +.empty-state p { font-size: 18px; margin-bottom: 16px; } + +/* Login */ +.login-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; +} + +.login-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + padding: 40px; + width: 320px; + text-align: center; +} + +.login-icon { font-size: 48px; margin-bottom: 12px; } +.login-card h1 { font-size: 24px; margin-bottom: 4px; } +.login-card .subtitle { color: var(--text-muted); margin-bottom: 24px; font-size: 14px; } + +.login-card input { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + color: var(--text); + font-size: 16px; + margin-bottom: 12px; + outline: none; +} + +.login-card input:focus { border-color: var(--accent); } + +.login-card button { + width: 100%; + padding: 12px; + border-radius: 10px; +} + +.login-card .error { color: var(--danger); font-size: 14px; margin-bottom: 8px; } + +/* Upload */ +.upload-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px; +} + +.upload-card h2 { margin-bottom: 16px; font-size: 20px; } + +.drop-zone { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 48px 24px; + text-align: center; + cursor: pointer; + transition: all 0.15s; + position: relative; +} + +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--accent); + background: rgba(0,122,255,0.05); +} + +.drop-zone input { position: absolute; inset: 0; opacity: 0; cursor: pointer; } +.drop-icon { font-size: 32px; color: var(--text-muted); margin-bottom: 8px; } +.drop-zone p { color: var(--text-muted); font-size: 14px; } + +.file-info { + display: flex; + justify-content: space-between; + padding: 12px 0; + font-size: 14px; + color: var(--text-muted); +} + +textarea { + width: 100%; + padding: 12px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + color: var(--text); + font-size: 14px; + font-family: inherit; + resize: vertical; + margin-bottom: 12px; + outline: none; +} + +textarea:focus { border-color: var(--accent); } + +.upload-card button[type="submit"] { width: 100%; } + +.progress { + height: 4px; + background: var(--border); + border-radius: 2px; + margin-top: 12px; + overflow: hidden; +} + +.progress-bar { + height: 100%; + background: var(--accent); + width: 0; + transition: width 0.2s; +} + +.result { margin-top: 16px; padding: 12px; border-radius: 10px; font-size: 14px; } +.result.success { background: rgba(48,209,88,0.1); color: var(--success); } +.result.error { background: rgba(255,59,48,0.1); color: var(--danger); } +.result a { color: var(--accent); } + +.api-info { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--border); +} + +.api-info h3 { font-size: 14px; color: var(--text-muted); margin-bottom: 8px; } + +.api-info pre { + background: var(--bg); + padding: 12px; + border-radius: 8px; + overflow-x: auto; + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +/* Modal */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + padding: 24px; +} + +.modal-content { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 16px; + max-width: 500px; + width: 100%; + max-height: 80vh; + overflow-y: auto; + padding: 24px; + position: relative; +} + +.modal-close { + position: absolute; + top: 12px; + right: 16px; + background: none; + border: none; + color: var(--text-muted); + font-size: 24px; + cursor: pointer; + padding: 4px; +} + +.modal-close:hover { color: var(--text); } + +.modal-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.modal-header .app-icon { width: 64px; height: 64px; } + +.build-list { display: flex; flex-direction: column; gap: 8px; } + +.build-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--bg); + border-radius: 10px; +} + +.build-meta { flex: 1; } +.build-version { font-weight: 600; font-size: 14px; } +.build-date { color: var(--text-muted); font-size: 12px; margin-top: 2px; } +.build-notes { color: var(--text-muted); font-size: 12px; margin-top: 4px; font-style: italic; } +.build-size { color: var(--text-muted); font-size: 12px; } + +.build-actions { display: flex; gap: 8px; align-items: center; } + +.delete-btn { + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + padding: 4px 8px; + border-radius: 6px; +} + +.delete-btn:hover { color: var(--danger); background: rgba(255,59,48,0.1); } + +.modal-footer { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--border); + text-align: right; +} + +/* Responsive */ +@media (max-width: 600px) { + header { padding: 12px 16px; } + header h1 { font-size: 17px; } + nav a { padding: 6px 10px; font-size: 13px; } + main { padding: 16px; } + .app-card { padding: 12px; gap: 12px; } + .app-icon { width: 48px; height: 48px; border-radius: 11px; font-size: 22px; } + .app-name { font-size: 15px; } + .upload-card { padding: 16px; } + .drop-zone { padding: 32px 16px; } +} + +/* --- Builder-specific additions --- */ + +.page-title { + font-size: 24px; + font-weight: 700; + margin-bottom: 16px; +} + +.section { margin-bottom: 24px; } +.section h2 { font-size: 16px; font-weight: 600; margin-bottom: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } + +/* Card (generic) */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; +} + +.card + .card { margin-top: 12px; } + +/* Form controls */ +label { display: block; font-size: 13px; color: var(--text-muted); margin-bottom: 6px; } + +input[type="text"], input[type="password"], input[type="url"], input[type="email"], select { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--bg); + color: var(--text); + font-size: 14px; + font-family: inherit; + margin-bottom: 12px; + outline: none; +} + +input:focus, select:focus { border-color: var(--accent); } + +.field-group { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +@media (max-width: 600px) { .field-group { grid-template-columns: 1fr; } } + +/* Buttons */ +.btn-sm { padding: 6px 14px; font-size: 13px; border-radius: 14px; } +.btn-secondary { + background: transparent; + color: var(--text); + border: 1px solid var(--border); +} +.btn-secondary:hover { background: var(--surface-hover); } + +.btn-row { display: flex; gap: 8px; flex-wrap: wrap; } + +/* Status badges */ +.badge { + display: inline-block; + padding: 3px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.badge.pending { background: rgba(255,149,0,0.15); color: #ff9500; } +.badge.running { background: rgba(0,122,255,0.15); color: var(--accent); } +.badge.succeeded { background: rgba(48,209,88,0.15); color: var(--success); } +.badge.failed { background: rgba(255,59,48,0.15); color: var(--danger); } +.badge.synced { background: rgba(48,209,88,0.15); color: var(--success); } +.badge.unsynced { background: rgba(255,149,0,0.15); color: #ff9500; } + +/* Tables */ +.table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border-radius: var(--radius); + overflow: hidden; +} + +.table th, .table td { + text-align: left; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + font-size: 14px; +} + +.table th { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; + background: var(--bg); +} + +.table tr:last-child td { border-bottom: none; } + +.table tbody tr:hover { background: var(--surface-hover); } + +.table .mono { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--text-muted); } + +/* Toast */ +.toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 12px 20px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + font-size: 14px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + z-index: 1000; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s, transform 0.2s; +} +.toast.show { opacity: 1; transform: translateY(0); } +.toast.success { border-color: var(--success); } +.toast.error { border-color: var(--danger); } + +/* Log viewer */ +.log-viewer { + background: #000; + color: #d0d0d0; + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + font-size: 12px; + line-height: 1.5; + padding: 16px; + border-radius: 10px; + max-height: 500px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; +} diff --git a/builder/public/js/devices.js b/builder/public/js/devices.js new file mode 100644 index 0000000..579458e --- /dev/null +++ b/builder/public/js/devices.js @@ -0,0 +1,83 @@ +const $ = (s) => document.querySelector(s); +const esc = (s) => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; }; + +function toast(msg, kind = '') { + const t = $('#toast'); + t.textContent = msg; + t.className = 'toast show ' + kind; + setTimeout(() => t.classList.remove('show'), 3500); +} + +async function load() { + const res = await fetch('/api/devices'); + if (res.status === 401) { location.href = '/login'; return; } + const devices = await res.json(); + const container = $('#devices-container'); + + if (!devices.length) { + container.innerHTML = '

No devices registered yet.

'; + return; + } + + container.innerHTML = ` + + + + + + ${devices.map(d => ` + + + + + + + + `).join('')} + +
NameUDIDStatusAdded
${esc(d.name) || 'unnamed'}${esc(d.udid)}${d.synced_at + ? 'Synced' + : 'Local only'}${esc(d.added_at)}
+ `; + + container.querySelectorAll('.delete-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Remove this device locally? (It will remain in the Apple portal.)')) return; + const udid = btn.getAttribute('data-udid'); + const r = await fetch(`/api/devices/${udid}`, { method: 'DELETE' }); + if (r.ok) { toast('Removed', 'success'); load(); } + else toast('Delete failed', 'error'); + }); + }); +} + +$('#add-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const form = e.target; + const body = { + udid: form.udid.value.trim(), + name: form.name.value.trim(), + }; + const btn = form.querySelector('button[type=submit]'); + btn.disabled = true; + btn.textContent = 'Registering…'; + + const r = await fetch('/api/devices', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await r.json().catch(() => ({})); + btn.disabled = false; + btn.textContent = 'Add Device'; + + if (r.ok) { + toast(data.synced ? 'Registered with Apple' : 'Saved locally (ASC not configured)', 'success'); + form.reset(); + load(); + } else { + toast(data.error || 'Register failed', 'error'); + } +}); + +load(); diff --git a/builder/public/js/settings.js b/builder/public/js/settings.js new file mode 100644 index 0000000..046c20a --- /dev/null +++ b/builder/public/js/settings.js @@ -0,0 +1,74 @@ +const $ = (sel) => document.querySelector(sel); +const toast = (msg, kind = '') => { + const t = $('#toast'); + t.textContent = msg; + t.className = 'toast show ' + kind; + setTimeout(() => t.classList.remove('show'), 3000); +}; + +async function load() { + const res = await fetch('/api/settings'); + if (res.status === 401) { location.href = '/login'; return; } + const s = await res.json(); + + $('[name=asc_key_id]').value = s.asc_key_id || ''; + $('[name=asc_issuer_id]').value = s.asc_issuer_id || ''; + $('[name=unraid_url]').value = s.unraid_url || ''; + $('[name=unraid_token]').value = s.unraid_token || ''; + + $('#p8-status').textContent = s.asc_key_uploaded + ? `✓ .p8 uploaded for key ${s.asc_key_id}` + : 'No .p8 uploaded yet'; +} + +async function saveForm(formEl, keys) { + const data = Object.fromEntries(keys.map(k => [k, formEl.querySelector(`[name=${k}]`)?.value || ''])); + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }); + if (res.ok) toast('Saved', 'success'); + else toast('Save failed', 'error'); +} + +$('#asc-form').addEventListener('submit', async (e) => { + e.preventDefault(); + await saveForm(e.target, ['asc_key_id', 'asc_issuer_id']); + + // Upload .p8 if one was selected + const file = $('#p8-input').files[0]; + if (file) { + const fd = new FormData(); + fd.append('p8', file); + const res = await fetch('/api/settings/p8', { method: 'POST', body: fd }); + if (res.ok) { + toast('.p8 uploaded', 'success'); + load(); + } else { + const err = await res.json().catch(() => ({})); + toast(err.error || 'Upload failed', 'error'); + } + } +}); + +$('#unraid-form').addEventListener('submit', async (e) => { + e.preventDefault(); + await saveForm(e.target, ['unraid_url', 'unraid_token']); +}); + +$('#test-asc').addEventListener('click', async () => { + const res = await fetch('/api/settings/test-asc', { method: 'POST' }); + const data = await res.json(); + if (res.ok) toast(`Connected — ${data.device_count} devices in portal`, 'success'); + else toast(data.error || 'Connection failed', 'error'); +}); + +$('#test-unraid').addEventListener('click', async () => { + const res = await fetch('/api/settings/test-unraid', { method: 'POST' }); + const data = await res.json(); + if (res.ok) toast(`Connected to unraid — ${data.app_count} apps`, 'success'); + else toast(data.error || 'Connection failed', 'error'); +}); + +load(); diff --git a/builder/src/asc-api.js b/builder/src/asc-api.js new file mode 100644 index 0000000..0825835 --- /dev/null +++ b/builder/src/asc-api.js @@ -0,0 +1,141 @@ +// App Store Connect API client. +// Authenticates with ES256 JWTs signed by the user's .p8 key. +// Docs: https://developer.apple.com/documentation/appstoreconnectapi + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { getSetting, DATA_DIR } = require('./db'); + +const API_BASE = 'https://api.appstoreconnect.apple.com'; +const AUDIENCE = 'appstoreconnect-v1'; +const TTL_SECONDS = 15 * 60; // Apple allows up to 20 min + +let cachedJwt = null; +let cachedExpiry = 0; + +function b64url(buf) { + return Buffer.from(buf) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function loadKey() { + const keyId = getSetting('asc_key_id'); + if (!keyId) throw new Error('ASC key ID not configured (Settings page)'); + const keyPath = path.join(DATA_DIR, 'asc', `${keyId}.p8`); + if (!fs.existsSync(keyPath)) throw new Error('.p8 file not uploaded'); + return { keyId, keyPem: fs.readFileSync(keyPath, 'utf8') }; +} + +function signJwt() { + // Return a cached token if still fresh (>60s of life left). + const now = Math.floor(Date.now() / 1000); + if (cachedJwt && cachedExpiry - now > 60) return cachedJwt; + + const issuerId = getSetting('asc_issuer_id'); + if (!issuerId) throw new Error('ASC Issuer ID not configured (Settings page)'); + const { keyId, keyPem } = loadKey(); + + const header = { alg: 'ES256', kid: keyId, typ: 'JWT' }; + const payload = { + iss: issuerId, + iat: now, + exp: now + TTL_SECONDS, + aud: AUDIENCE, + }; + + const headerB64 = b64url(JSON.stringify(header)); + const payloadB64 = b64url(JSON.stringify(payload)); + const signingInput = `${headerB64}.${payloadB64}`; + + const signer = crypto.createSign('SHA256'); + signer.update(signingInput); + signer.end(); + + // Apple's .p8 files are PKCS8 EC keys. Node signs them as DER by default; + // we need the raw IEEE P1363 r||s form for JWS. + const derSig = signer.sign({ key: keyPem, dsaEncoding: 'ieee-p1363' }); + const sigB64 = b64url(derSig); + + cachedJwt = `${signingInput}.${sigB64}`; + cachedExpiry = now + TTL_SECONDS; + return cachedJwt; +} + +async function ascFetch(pathAndQuery, init = {}) { + const token = signJwt(); + const url = `${API_BASE}${pathAndQuery}`; + const res = await fetch(url, { + ...init, + headers: { + ...(init.headers || {}), + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + const text = await res.text(); + let body = null; + if (text) { + try { body = JSON.parse(text); } + catch { body = { raw: text }; } + } + if (!res.ok) { + const err = body?.errors?.[0]; + const msg = err + ? `${err.title || 'ASC error'}: ${err.detail || err.code || ''}` + : `ASC request failed (${res.status})`; + const e = new Error(msg); + e.status = res.status; + e.body = body; + throw e; + } + return body; +} + +// --- Public API --- + +async function listDevices() { + // ASC paginates; 200 is the max per page. For a personal store, one page is plenty. + const body = await ascFetch('/v1/devices?limit=200'); + return body.data || []; +} + +async function registerDevice({ udid, name, platform = 'IOS' }) { + const body = await ascFetch('/v1/devices', { + method: 'POST', + body: JSON.stringify({ + data: { + type: 'devices', + attributes: { name: name || udid.slice(0, 8), udid, platform }, + }, + }), + }); + return body.data; +} + +async function listBundleIds(identifier) { + const q = identifier ? `?filter[identifier]=${encodeURIComponent(identifier)}` : ''; + const body = await ascFetch(`/v1/bundleIds${q}`); + return body.data || []; +} + +async function listProfiles() { + const body = await ascFetch('/v1/profiles?limit=200'); + return body.data || []; +} + +async function deleteProfile(profileId) { + await ascFetch(`/v1/profiles/${profileId}`, { method: 'DELETE' }); +} + +module.exports = { + signJwt, + listDevices, + registerDevice, + listBundleIds, + listProfiles, + deleteProfile, +}; diff --git a/builder/src/auth.js b/builder/src/auth.js new file mode 100644 index 0000000..80f4629 --- /dev/null +++ b/builder/src/auth.js @@ -0,0 +1,27 @@ +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; +const BUILDER_SHARED_SECRET = process.env.BUILDER_SHARED_SECRET; + +// Session auth for the browser UI +function requireLogin(req, res, next) { + if (req.session && req.session.authenticated) return next(); + if (req.headers.accept?.includes('json') || req.path.startsWith('/api/')) { + return res.status(401).json({ error: 'Not authenticated' }); + } + res.redirect('/login'); +} + +// Shared-secret auth for enrollment callbacks coming from unraid +function requireBuilderSecret(req, res, next) { + const header = req.headers['authorization'] || ''; + const match = header.match(/^Bearer\s+(.+)$/); + if (!match || !BUILDER_SHARED_SECRET || match[1] !== BUILDER_SHARED_SECRET) { + return res.status(401).json({ error: 'Invalid shared secret' }); + } + next(); +} + +function validatePassword(password) { + return password && password === ADMIN_PASSWORD; +} + +module.exports = { requireLogin, requireBuilderSecret, validatePassword }; diff --git a/builder/src/db.js b/builder/src/db.js new file mode 100644 index 0000000..3fd8df3 --- /dev/null +++ b/builder/src/db.js @@ -0,0 +1,80 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data'); +fs.mkdirSync(DATA_DIR, { recursive: true }); + +const db = new Database(path.join(DATA_DIR, 'builder.db')); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + + CREATE TABLE IF NOT EXISTS devices ( + udid TEXT PRIMARY KEY, + name TEXT, + model TEXT, + platform TEXT DEFAULT 'IOS', + apple_device_id TEXT, + synced_at TEXT, + added_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS apps ( + id TEXT PRIMARY KEY, + bundle_id TEXT UNIQUE NOT NULL, + name TEXT, + scheme TEXT, + team_id TEXT, + last_built_at TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS profiles ( + bundle_id TEXT PRIMARY KEY, + profile_uuid TEXT, + name TEXT, + team_id TEXT, + expires_at TEXT, + device_count INTEGER, + path TEXT, + updated_at TEXT + ); + + CREATE TABLE IF NOT EXISTS build_jobs ( + id TEXT PRIMARY KEY, + app_id TEXT, + bundle_id TEXT, + source_kind TEXT, + source_ref TEXT, + scheme TEXT, + status TEXT DEFAULT 'pending', + started_at TEXT, + finished_at TEXT, + log_path TEXT, + ipa_path TEXT, + unraid_build_id TEXT, + install_url TEXT, + error TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); +`); + +function getSetting(key) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + return row ? row.value : null; +} + +function setSetting(key, value) { + db.prepare(` + INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `).run(key, value); +} + +module.exports = { db, getSetting, setSetting, DATA_DIR }; diff --git a/builder/src/profile-manager.js b/builder/src/profile-manager.js new file mode 100644 index 0000000..1b0be96 --- /dev/null +++ b/builder/src/profile-manager.js @@ -0,0 +1,215 @@ +// Profile manager: wraps `fastlane sigh` to generate/cache ad-hoc provisioning profiles +// keyed by bundle identifier. Handles ASC key JSON materialization, profile parsing, +// cache invalidation, and installation into ~/Library/MobileDevice/Provisioning Profiles/. + +const path = require('path'); +const fs = require('fs'); +const os = require('os'); +const { execFile } = require('child_process'); +const { promisify } = require('util'); +const execFileAsync = promisify(execFile); + +const { db, getSetting, DATA_DIR } = require('./db'); + +const PROFILES_DIR = path.join(DATA_DIR, 'profiles'); +const ASC_DIR = path.join(DATA_DIR, 'asc'); +const FASTLANE_DIR = path.join(__dirname, '..', 'fastlane'); +const INSTALLED_PROFILES_DIR = path.join(os.homedir(), 'Library/MobileDevice/Provisioning Profiles'); + +fs.mkdirSync(PROFILES_DIR, { recursive: true }); + +// Minimum lifetime on a cached profile before we regenerate it proactively. +const MIN_LIFETIME_DAYS = 30; + +function buildAscKeyJsonPath() { + const keyId = getSetting('asc_key_id'); + const issuerId = getSetting('asc_issuer_id'); + if (!keyId || !issuerId) throw new Error('ASC key id / issuer id not configured'); + const p8Path = path.join(ASC_DIR, `${keyId}.p8`); + if (!fs.existsSync(p8Path)) throw new Error('.p8 file not uploaded'); + const keyContent = fs.readFileSync(p8Path, 'utf8'); + const jsonPath = path.join(ASC_DIR, `${keyId}.json`); + const json = { + key_id: keyId, + issuer_id: issuerId, + key: keyContent, + duration: 1200, + in_house: false, + }; + fs.writeFileSync(jsonPath, JSON.stringify(json), { mode: 0o600 }); + return jsonPath; +} + +function parseMobileprovision(filePath) { + // Extract the plist contents from the CMS-wrapped .mobileprovision via `security cms -D`. + // Falls back to a regex scan if `security` isn't available. + const { execFileSync } = require('child_process'); + let xml; + try { + xml = execFileSync('/usr/bin/security', ['cms', '-D', '-i', filePath], { + encoding: 'utf8', + }); + } catch { + const raw = fs.readFileSync(filePath); + const start = raw.indexOf(''); + if (start === -1 || end === -1) throw new Error('Could not parse mobileprovision'); + xml = raw.slice(start, end + ''.length).toString('utf8'); + } + + const pick = (key) => { + const re = new RegExp(`${key}\\s*([^<]+)`); + const m = xml.match(re); + return m ? m[1] : null; + }; + const pickDate = (key) => { + const re = new RegExp(`${key}\\s*([^<]+)`); + const m = xml.match(re); + return m ? m[1] : null; + }; + + const uuid = pick('UUID'); + const name = pick('Name'); + const teamId = pick('TeamIdentifier') + || (xml.match(/TeamIdentifier<\/key>\s*\s*([^<]+)<\/string>/)?.[1] ?? null); + const expiresAt = pickDate('ExpirationDate'); + + // Devices count from the ProvisionedDevices array. + const devicesMatch = xml.match(/ProvisionedDevices<\/key>\s*([\s\S]*?)<\/array>/); + const deviceCount = devicesMatch ? (devicesMatch[1].match(//g) || []).length : 0; + + return { uuid, name, teamId, expiresAt, deviceCount }; +} + +function installProfile(srcPath, uuid) { + fs.mkdirSync(INSTALLED_PROFILES_DIR, { recursive: true }); + const dest = path.join(INSTALLED_PROFILES_DIR, `${uuid}.mobileprovision`); + fs.copyFileSync(srcPath, dest); + return dest; +} + +function cachedRow(bundleId) { + return db.prepare('SELECT * FROM profiles WHERE bundle_id = ?').get(bundleId); +} + +function isCacheFresh(row) { + if (!row || !row.updated_at || !row.path || !fs.existsSync(row.path)) return false; + if (!row.expires_at) return false; + const expiresMs = Date.parse(row.expires_at); + if (Number.isNaN(expiresMs)) return false; + const daysLeft = (expiresMs - Date.now()) / (1000 * 60 * 60 * 24); + return daysLeft >= MIN_LIFETIME_DAYS; +} + +async function runFastlaneSigh({ bundleId, outputPath, apiKeyJson, logStream }) { + const args = [ + 'run', + 'sigh', + `adhoc:true`, + `force:true`, + `app_identifier:${bundleId}`, + `api_key_path:${apiKeyJson}`, + `output_path:${outputPath}`, + `skip_install:true`, + ]; + + const child = execFile('/opt/homebrew/bin/fastlane', args, { + cwd: FASTLANE_DIR, + env: { ...process.env, FASTLANE_DISABLE_COLORS: '1' }, + maxBuffer: 20 * 1024 * 1024, + }); + + if (logStream) { + child.stdout.on('data', (chunk) => logStream.write(chunk)); + child.stderr.on('data', (chunk) => logStream.write(chunk)); + } + + let stdout = '', stderr = ''; + child.stdout.on('data', (c) => { stdout += c.toString(); }); + child.stderr.on('data', (c) => { stderr += c.toString(); }); + + return new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve({ stdout, stderr }); + else reject(new Error(`fastlane sigh exited ${code}: ${stderr.slice(-2000) || stdout.slice(-2000)}`)); + }); + }); +} + +async function getProfile(bundleId, { force = false, logStream = null } = {}) { + if (!bundleId) throw new Error('bundleId is required'); + + const existing = cachedRow(bundleId); + if (!force && isCacheFresh(existing)) { + // Make sure it's installed locally so xcodebuild can find it. + try { installProfile(existing.path, existing.profile_uuid); } catch {} + return { ...existing, fromCache: true }; + } + + const apiKeyJson = buildAscKeyJsonPath(); + const outputPath = path.join(PROFILES_DIR, bundleId); + fs.mkdirSync(outputPath, { recursive: true }); + + await runFastlaneSigh({ bundleId, outputPath, apiKeyJson, logStream }); + + // Find the .mobileprovision fastlane produced. + const candidates = fs.readdirSync(outputPath) + .filter((f) => f.endsWith('.mobileprovision')) + .map((f) => ({ + name: f, + path: path.join(outputPath, f), + mtime: fs.statSync(path.join(outputPath, f)).mtimeMs, + })) + .sort((a, b) => b.mtime - a.mtime); + + if (!candidates.length) { + throw new Error('fastlane sigh succeeded but no .mobileprovision was produced'); + } + + const produced = candidates[0]; + const parsed = parseMobileprovision(produced.path); + if (!parsed.uuid) throw new Error('Could not parse UUID from produced profile'); + + // Normalize storage: rename to .mobileprovision inside the per-bundle dir. + const finalPath = path.join(outputPath, `${parsed.uuid}.mobileprovision`); + if (produced.path !== finalPath) { + fs.renameSync(produced.path, finalPath); + } + + db.prepare(` + INSERT INTO profiles (bundle_id, profile_uuid, name, team_id, expires_at, device_count, path, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(bundle_id) DO UPDATE SET + profile_uuid = excluded.profile_uuid, + name = excluded.name, + team_id = excluded.team_id, + expires_at = excluded.expires_at, + device_count = excluded.device_count, + path = excluded.path, + updated_at = excluded.updated_at + `).run( + bundleId, + parsed.uuid, + parsed.name, + parsed.teamId, + parsed.expiresAt, + parsed.deviceCount, + finalPath, + ); + + installProfile(finalPath, parsed.uuid); + + return { + bundle_id: bundleId, + profile_uuid: parsed.uuid, + name: parsed.name, + team_id: parsed.teamId, + expires_at: parsed.expiresAt, + device_count: parsed.deviceCount, + path: finalPath, + fromCache: false, + }; +} + +module.exports = { getProfile, parseMobileprovision }; diff --git a/builder/src/server.js b/builder/src/server.js new file mode 100644 index 0000000..ec1b13a --- /dev/null +++ b/builder/src/server.js @@ -0,0 +1,251 @@ +// Load .env from builder/ if present (non-overriding — launchd env wins). +(() => { + const envPath = require('path').join(__dirname, '..', '.env'); + if (!require('fs').existsSync(envPath)) return; + const content = require('fs').readFileSync(envPath, 'utf8'); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + const val = trimmed.slice(eq + 1).trim().replace(/^['"]|['"]$/g, ''); + if (!(key in process.env)) process.env[key] = val; + } +})(); + +const express = require('express'); +const session = require('express-session'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); + +const { db, getSetting, setSetting, DATA_DIR } = require('./db'); +const { requireLogin, validatePassword } = require('./auth'); + +const app = express(); +const PORT = process.env.PORT || 3090; +const ASC_DIR = path.join(DATA_DIR, 'asc'); +fs.mkdirSync(ASC_DIR, { recursive: true }); + +const p8Upload = multer({ + dest: path.join(DATA_DIR, 'tmp'), + limits: { fileSize: 64 * 1024 }, + fileFilter: (req, file, cb) => { + if (file.originalname.toLowerCase().endsWith('.p8')) return cb(null, true); + cb(new Error('Only .p8 files allowed')); + }, +}); + +app.use(express.json({ limit: '1mb' })); +app.use(express.urlencoded({ extended: true })); +app.use(session({ + secret: process.env.SESSION_SECRET || 'dev-secret-change-me', + resave: false, + saveUninitialized: false, + cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 }, +})); +app.use(express.static(path.join(__dirname, '..', 'public'))); + +// --- Auth --- + +app.get('/login', (req, res) => { + if (req.session.authenticated) return res.redirect('/'); + res.sendFile(path.join(__dirname, '..', 'views', 'login.html')); +}); + +app.post('/login', (req, res) => { + if (validatePassword(req.body.password)) { + req.session.authenticated = true; + res.redirect('/'); + } else { + res.redirect('/login?error=1'); + } +}); + +app.get('/logout', (req, res) => { + req.session.destroy(); + res.redirect('/login'); +}); + +// --- Pages --- + +app.get('/', requireLogin, (req, res) => { + res.sendFile(path.join(__dirname, '..', 'views', 'index.html')); +}); + +app.get('/settings', requireLogin, (req, res) => { + res.sendFile(path.join(__dirname, '..', 'views', 'settings.html')); +}); + +app.get('/devices', requireLogin, (req, res) => { + res.sendFile(path.join(__dirname, '..', 'views', 'devices.html')); +}); + +// --- Device API --- + +function invalidateProfilesForDeviceChange() { + db.prepare('UPDATE profiles SET updated_at = NULL').run(); +} + +app.get('/api/devices', requireLogin, (req, res) => { + const rows = db.prepare('SELECT * FROM devices ORDER BY added_at DESC').all(); + res.json(rows); +}); + +app.post('/api/devices', requireLogin, async (req, res) => { + const { udid, name, model, platform = 'IOS' } = req.body || {}; + if (!udid || typeof udid !== 'string') { + return res.status(400).json({ error: 'UDID is required' }); + } + + // Upsert locally first so we always have a record even if Apple call fails. + db.prepare(` + INSERT INTO devices (udid, name, model, platform) + VALUES (?, ?, ?, ?) + ON CONFLICT(udid) DO UPDATE SET + name = COALESCE(NULLIF(excluded.name, ''), devices.name), + model = COALESCE(NULLIF(excluded.model, ''), devices.model), + platform = excluded.platform + `).run(udid, name || null, model || null, platform); + + // Try to register with Apple. + let synced = false; + try { + const asc = require('./asc-api'); + const appleDevice = await asc.registerDevice({ udid, name, platform }); + const appleDeviceId = appleDevice?.id || null; + db.prepare(` + UPDATE devices + SET apple_device_id = ?, synced_at = datetime('now') + WHERE udid = ? + `).run(appleDeviceId, udid); + synced = true; + invalidateProfilesForDeviceChange(); + } catch (err) { + // Don't fail the request; the device is saved locally. + console.warn('[devices] ASC sync failed:', err.message); + return res.json({ success: true, synced: false, warning: err.message }); + } + + res.json({ success: true, synced }); +}); + +app.delete('/api/devices/:udid', requireLogin, (req, res) => { + db.prepare('DELETE FROM devices WHERE udid = ?').run(req.params.udid); + invalidateProfilesForDeviceChange(); + res.json({ success: true }); +}); + +// --- Settings API --- + +const SETTINGS_KEYS = [ + 'asc_key_id', + 'asc_issuer_id', + 'unraid_url', + 'unraid_token', +]; + +app.get('/api/settings', requireLogin, (req, res) => { + const out = {}; + for (const k of SETTINGS_KEYS) { + out[k] = getSetting(k) || ''; + } + // Never expose the raw token; just indicate whether it's set + out.unraid_token = out.unraid_token ? '••••••••' : ''; + // Has the p8 been uploaded? + const keyId = out.asc_key_id; + out.asc_key_uploaded = keyId + ? fs.existsSync(path.join(DATA_DIR, 'asc', `${keyId}.p8`)) + : false; + res.json(out); +}); + +app.post('/api/settings', requireLogin, (req, res) => { + const { asc_key_id, asc_issuer_id, unraid_url, unraid_token } = req.body; + if (asc_key_id !== undefined) setSetting('asc_key_id', asc_key_id || ''); + if (asc_issuer_id !== undefined) setSetting('asc_issuer_id', asc_issuer_id || ''); + if (unraid_url !== undefined) setSetting('unraid_url', unraid_url || ''); + // Only update the token if a real value was provided (not the placeholder) + if (unraid_token && unraid_token !== '••••••••') { + setSetting('unraid_token', unraid_token); + } + res.json({ success: true }); +}); + +app.post('/api/settings/p8', requireLogin, p8Upload.single('p8'), (req, res) => { + try { + if (!req.file) return res.status(400).json({ error: 'No file' }); + const keyId = getSetting('asc_key_id'); + if (!keyId) { + fs.unlinkSync(req.file.path); + return res.status(400).json({ error: 'Save Key ID before uploading .p8' }); + } + const dest = path.join(ASC_DIR, `${keyId}.p8`); + fs.renameSync(req.file.path, dest); + fs.chmodSync(dest, 0o600); + res.json({ success: true }); + } catch (err) { + if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path); + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/settings/test-asc', requireLogin, async (req, res) => { + try { + const asc = require('./asc-api'); + const devices = await asc.listDevices(); + res.json({ success: true, device_count: devices.length }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +app.post('/api/settings/test-unraid', requireLogin, async (req, res) => { + try { + const url = getSetting('unraid_url'); + const token = getSetting('unraid_token'); + if (!url || !token) return res.status(400).json({ error: 'Set URL and token first' }); + const r = await fetch(`${url}/api/apps`, { headers: { 'X-Api-Token': token } }); + if (!r.ok) return res.status(400).json({ error: `unraid returned ${r.status}` }); + const apps = await r.json(); + res.json({ success: true, app_count: apps.length }); + } catch (err) { + res.status(400).json({ error: err.message }); + } +}); + +// --- Profile API --- + +app.get('/api/profile/:bundleId', requireLogin, async (req, res) => { + try { + const profileManager = require('./profile-manager'); + const force = req.query.force === '1'; + const info = await profileManager.getProfile(req.params.bundleId, { force }); + if (req.query.download === '1') { + res.set('Content-Type', 'application/x-apple-aspen-config'); + res.set('Content-Disposition', `attachment; filename="${info.profile_uuid}.mobileprovision"`); + return res.sendFile(info.path); + } + res.json({ success: true, profile: info }); + } catch (err) { + console.error('[profile]', err); + res.status(500).json({ error: err.message }); + } +}); + +// --- Health --- + +app.get('/api/health', (req, res) => { + res.json({ + status: 'ok', + version: '1.0.0', + service: 'ios-appstore-builder', + host: require('os').hostname(), + }); +}); + +app.listen(PORT, '0.0.0.0', () => { + console.log(`iOS App Store Builder running on port ${PORT}`); + console.log(`Data dir: ${DATA_DIR}`); +}); diff --git a/builder/views/_partial_nav.html b/builder/views/_partial_nav.html new file mode 100644 index 0000000..a9ff8d9 --- /dev/null +++ b/builder/views/_partial_nav.html @@ -0,0 +1,10 @@ + +
+

🔨 Builder

+ +
diff --git a/builder/views/devices.html b/builder/views/devices.html new file mode 100644 index 0000000..aa86dcf --- /dev/null +++ b/builder/views/devices.html @@ -0,0 +1,56 @@ + + + + + + Devices - Builder + + + +
+

🔨 Builder

+ +
+ +
+

Devices

+ +
+

Register a device

+
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+ +
+

Registered devices

+
+

Loading…

+
+
+ +
+
+ + + + diff --git a/builder/views/index.html b/builder/views/index.html new file mode 100644 index 0000000..c2e4c84 --- /dev/null +++ b/builder/views/index.html @@ -0,0 +1,27 @@ + + + + + + Builder + + + +
+

🔨 Builder

+ +
+ +
+

Builds

+
+

No builds yet. Builds will appear here once the build pipeline (Phase 4) is wired up.

+
+
+ + diff --git a/builder/views/login.html b/builder/views/login.html new file mode 100644 index 0000000..054a7a6 --- /dev/null +++ b/builder/views/login.html @@ -0,0 +1,29 @@ + + + + + + App Store Builder - Login + + + + + + diff --git a/builder/views/settings.html b/builder/views/settings.html new file mode 100644 index 0000000..9f5f7ef --- /dev/null +++ b/builder/views/settings.html @@ -0,0 +1,69 @@ + + + + + + Settings - Builder + + + +
+

🔨 Builder

+ +
+ +
+

Settings

+ +
+

App Store Connect API

+
+
+
+
+ + +
+
+ + +
+
+ + +

+
+ + +
+
+
+
+ +
+

unraid App Store

+
+
+ + + + +
+ + +
+
+
+
+ +
+
+ + + +