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 = `
+
+
+ | Name | UDID | Status | Added | |
+
+
+ ${devices.map(d => `
+
+ | ${esc(d.name) || 'unnamed'} |
+ ${esc(d.udid)} |
+ ${d.synced_at
+ ? 'Synced'
+ : 'Local only'} |
+ ${esc(d.added_at)} |
+ |
+
+ `).join('')}
+
+
+ `;
+
+ 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 @@
+
+
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
+
+
+
+
+
+
+ Devices
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+ 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
+
+
+
+
+
🔨
+
App Store Builder
+
Mac mini build console
+
+
+
+
+
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
+
+
+
+
+
+
+ Settings
+
+
+
App Store Connect API
+
+
+
+
+
+
+
+
+
+
+