Builder v2: local project browser + multi-team ASC keys
Rewrites the builder console to browse local Xcode projects instead of accepting source uploads or git URLs. Replaces the devices page with a profiles page that manages ad-hoc provisioning profiles and lists registered bundle IDs per team. Adds multi-account support: ASC API keys are now stored in an asc_keys table keyed by team_id (team_name, key_id, issuer_id, p8_filename). At build time, the worker reads DEVELOPMENT_TEAM from the Xcode project and auto-picks the matching key for fastlane sigh + JWT signing. Legacy single-key settings auto-migrate on first boot. Fixes storefront IPA parser to handle binary plists produced by Xcode. Drops the enrollment bridge, device management routes, and direct ASC API client -- fastlane sigh handles profile lifecycle now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,8 @@ The split exists because `xcodebuild` needs macOS and the Mac mini is the only m
|
||||
- **Data**: `/Users/m4mini/AppStoreBuilder/data/` (SQLite + ASC keys + source archives + build artifacts + logs)
|
||||
- **Process supervision**: launchd — `~/Library/LaunchAgents/com.88oak.appstorebuilder.plist` (KeepAlive, RunAtLoad)
|
||||
- **Env vars** (in `builder/.env`, loaded non-destructively by `src/server.js`):
|
||||
- `ADMIN_PASSWORD`, `SESSION_SECRET`, `DATA_DIR`, `PORT`, `BUILDER_SHARED_SECRET`
|
||||
- `ADMIN_PASSWORD`, `SESSION_SECRET`, `DATA_DIR`, `PORT`
|
||||
- **ASC API keys** live in the `asc_keys` table (one row per Apple Developer team), not in env/settings. Columns: `team_id`, `team_name`, `key_id`, `issuer_id`, `p8_filename`. Managed at `/settings` → "Developer Accounts". `.p8` files stored at `$DATA_DIR/asc/<key_id>.p8` (0600). At build time, the worker reads `DEVELOPMENT_TEAM` from `xcodebuild -showBuildSettings` and looks up the matching key.
|
||||
|
||||
**Important**: The builder code must NOT live under `~/Desktop/` when running via launchd. macOS TCC blocks launchd-spawned processes from reading Desktop, which causes the Node process to hang on `__getcwd` during startup. That's why we copy to `/Users/m4mini/AppStoreBuilder/app/` via the deploy script instead of pointing launchd directly at the git checkout in `~/Desktop/code/ios-appstore/builder/`.
|
||||
|
||||
|
||||
@@ -443,6 +443,37 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.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; }
|
||||
.badge-valid { background: rgba(48,209,88,0.15); color: var(--success); display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }
|
||||
.badge-expiring { background: rgba(255,149,0,0.15); color: #ff9500; display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }
|
||||
.badge-expired { background: rgba(255,59,48,0.15); color: var(--danger); display:inline-block; padding:3px 10px; border-radius:10px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:0.03em; }
|
||||
|
||||
/* Data tables (settings / profiles) */
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th, .data-table td {
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.data-table th {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
.data-table code { font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--text-muted); }
|
||||
|
||||
.action-cell { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
.btn-xs { padding: 4px 10px; font-size: 12px; border-radius: 6px; cursor: pointer; border: 1px solid var(--border); }
|
||||
.btn-xs.btn-secondary { background: transparent; color: var(--text); }
|
||||
.btn-xs.btn-secondary:hover { background: var(--surface-hover); }
|
||||
.btn-danger { background: transparent; color: var(--danger); border: 1px solid var(--danger); padding: 4px 10px; font-size: 12px; border-radius: 6px; cursor: pointer; }
|
||||
.btn-danger:hover { background: rgba(255,59,48,0.1); }
|
||||
.upload-label { display: inline-flex; align-items: center; cursor: pointer; }
|
||||
|
||||
/* Tables */
|
||||
.table {
|
||||
@@ -495,6 +526,20 @@ input:focus, select:focus { border-color: var(--accent); }
|
||||
.toast.success { border-color: var(--success); }
|
||||
.toast.error { border-color: var(--danger); }
|
||||
|
||||
/* Filesystem browser */
|
||||
.path-bar { display: flex; gap: 4px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
|
||||
.path-segment { color: var(--accent); cursor: pointer; font-size: 14px; font-family: ui-monospace, 'SF Mono', Menlo, monospace; }
|
||||
.path-segment:hover { text-decoration: underline; }
|
||||
.path-separator { color: var(--text-muted); font-size: 12px; user-select: none; }
|
||||
|
||||
.file-list { display: flex; flex-direction: column; }
|
||||
.file-entry { display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 8px; cursor: pointer; transition: background 0.1s; }
|
||||
.file-entry:hover { background: var(--surface-hover); }
|
||||
.file-entry.selected { background: rgba(0,122,255,0.1); border: 1px solid rgba(0,122,255,0.3); }
|
||||
.file-entry .icon { width: 24px; text-align: center; font-size: 16px; flex-shrink: 0; }
|
||||
.file-entry .name { font-size: 14px; }
|
||||
.file-entry.xcode-project .name { color: var(--accent); font-weight: 600; }
|
||||
|
||||
/* Log viewer */
|
||||
.log-viewer {
|
||||
background: #000;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const esc = (s) => { const d = document.createElement('div'); d.textContent = s ?? ''; return d.innerHTML; };
|
||||
|
||||
let selectedProject = null;
|
||||
|
||||
function toast(msg, kind = '') {
|
||||
const t = $('#toast');
|
||||
t.textContent = msg;
|
||||
@@ -6,46 +10,147 @@ function toast(msg, kind = '') {
|
||||
setTimeout(() => t.classList.remove('show'), 3500);
|
||||
}
|
||||
|
||||
$('#upload-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const btn = e.target.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Uploading…';
|
||||
// --- Path bar (breadcrumbs) ---
|
||||
|
||||
function renderPathBar(currentPath) {
|
||||
const bar = $('#path-bar');
|
||||
const segments = currentPath.split('/').filter(Boolean);
|
||||
let html = '';
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const fullPath = '/' + segments.slice(0, i + 1).join('/');
|
||||
if (i > 0) html += '<span class="path-separator">/</span>';
|
||||
html += `<span class="path-segment" data-path="${esc(fullPath)}">${esc(segments[i])}</span>`;
|
||||
}
|
||||
bar.innerHTML = html;
|
||||
bar.querySelectorAll('.path-segment').forEach((el) => {
|
||||
el.addEventListener('click', () => browse(el.dataset.path));
|
||||
});
|
||||
}
|
||||
|
||||
// --- File list ---
|
||||
|
||||
function renderFileList(entries) {
|
||||
const list = $('#file-list');
|
||||
|
||||
if (!entries.length) {
|
||||
list.innerHTML = '<p style="color:var(--text-muted);padding:8px 0">No Xcode projects or subdirectories found here.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = entries.map((e) => {
|
||||
let icon, cls;
|
||||
if (e.type === 'xcworkspace') {
|
||||
icon = '\u{1F4E6}';
|
||||
cls = 'xcode-project';
|
||||
} else if (e.type === 'xcodeproj') {
|
||||
icon = '\u{1F528}';
|
||||
cls = 'xcode-project';
|
||||
} else {
|
||||
icon = '\u{1F4C1}';
|
||||
cls = '';
|
||||
}
|
||||
return `<div class="file-entry ${cls}" data-path="${esc(e.path)}" data-type="${e.type}">
|
||||
<span class="icon">${icon}</span>
|
||||
<span class="name">${esc(e.name)}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
list.querySelectorAll('.file-entry').forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
if (el.dataset.type === 'directory') {
|
||||
browse(el.dataset.path);
|
||||
} else {
|
||||
selectProject(el.dataset.path);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Browse directory ---
|
||||
|
||||
async function browse(dirPath) {
|
||||
const list = $('#file-list');
|
||||
list.innerHTML = '<p style="color:var(--text-muted)">Loading...</p>';
|
||||
|
||||
const url = dirPath
|
||||
? `/api/filesystem/browse?path=${encodeURIComponent(dirPath)}`
|
||||
: '/api/filesystem/browse';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/build/upload', { method: 'POST', body: fd });
|
||||
const r = await fetch(url);
|
||||
if (r.status === 401) { location.href = '/login'; return; }
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Upload failed');
|
||||
location.href = `/builds#${data.job_id}`;
|
||||
if (!r.ok) throw new Error(data.error);
|
||||
|
||||
renderPathBar(data.path);
|
||||
renderFileList(data.entries);
|
||||
} catch (err) {
|
||||
list.innerHTML = `<p style="color:var(--danger)">${esc(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Select a project ---
|
||||
|
||||
async function selectProject(projectPath) {
|
||||
selectedProject = projectPath;
|
||||
const config = $('#project-config');
|
||||
config.style.display = 'block';
|
||||
$('#selected-project').textContent = projectPath;
|
||||
|
||||
const select = $('#scheme-select');
|
||||
select.disabled = true;
|
||||
select.innerHTML = '<option>Loading schemes...</option>';
|
||||
$('#build-btn').disabled = true;
|
||||
|
||||
document.querySelectorAll('.file-entry').forEach((el) => {
|
||||
el.classList.toggle('selected', el.dataset.path === projectPath);
|
||||
});
|
||||
|
||||
try {
|
||||
const r = await fetch(`/api/filesystem/schemes?projectPath=${encodeURIComponent(projectPath)}`);
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error);
|
||||
|
||||
if (!data.schemes.length) {
|
||||
select.innerHTML = '<option>No schemes found</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = data.schemes.map((s) =>
|
||||
`<option value="${esc(s)}">${esc(s)}</option>`
|
||||
).join('');
|
||||
select.disabled = false;
|
||||
$('#build-btn').disabled = false;
|
||||
} catch (err) {
|
||||
select.innerHTML = `<option>Error: ${err.message}</option>`;
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build button ---
|
||||
|
||||
$('#build-btn').addEventListener('click', async () => {
|
||||
if (!selectedProject) return;
|
||||
const scheme = $('#scheme-select').value;
|
||||
const btn = $('#build-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Starting build...';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/build/local', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectPath: selectedProject, scheme }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Build failed to start');
|
||||
location.href = `/#${data.job_id}`;
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Queue Build';
|
||||
btn.textContent = 'Build';
|
||||
}
|
||||
});
|
||||
|
||||
$('#git-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
url: e.target.url.value.trim(),
|
||||
branch: e.target.branch.value.trim() || null,
|
||||
scheme: e.target.scheme.value.trim() || null,
|
||||
};
|
||||
const btn = e.target.querySelector('button[type=submit]');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Cloning…';
|
||||
try {
|
||||
const r = await fetch('/api/build/git', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || 'Clone failed');
|
||||
location.href = `/builds#${data.job_id}`;
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Queue Build';
|
||||
}
|
||||
});
|
||||
// Start browsing at default path
|
||||
browse(null);
|
||||
|
||||
@@ -40,14 +40,14 @@ async function loadJobs() {
|
||||
container.innerHTML = `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Status</th><th>Bundle</th><th>Source</th><th>Started</th><th>Duration</th><th></th></tr>
|
||||
<tr><th>Status</th><th>Bundle</th><th>Project</th><th>Started</th><th>Duration</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${jobs.map(j => `
|
||||
<tr data-id="${j.id}" style="cursor:pointer">
|
||||
<td>${statusBadge(j.status)}</td>
|
||||
<td class="mono">${esc(j.bundle_id) || '<span style="color:var(--text-muted)">—</span>'}</td>
|
||||
<td class="mono">${esc(j.source_kind)}: ${esc((j.source_ref || '').slice(0, 40))}</td>
|
||||
<td class="mono">${esc((j.source_ref || '').replace(/\.(xcodeproj|xcworkspace)$/, ''))}</td>
|
||||
<td class="mono">${esc(formatDate(j.started_at))}</td>
|
||||
<td class="mono">${esc(duration(j.started_at, j.finished_at))}</td>
|
||||
<td>${j.install_url ? `<a href="${esc(j.install_url)}" class="btn-sm" style="background:var(--accent);color:white;padding:5px 12px;border-radius:14px;text-decoration:none;font-size:12px">Install</a>` : ''}</td>
|
||||
@@ -72,7 +72,7 @@ async function openJob(id) {
|
||||
$('#detail-meta').innerHTML = `
|
||||
<div>bundle: ${esc(job.bundle_id || '—')}</div>
|
||||
<div>scheme: ${esc(job.scheme || '—')}</div>
|
||||
<div>source: ${esc(job.source_kind)} ${esc(job.source_ref || '')}</div>
|
||||
<div>project: ${esc(job.source_ref || job.project_path || '--')}</div>
|
||||
<div>started: ${esc(formatDate(job.started_at))} · finished: ${esc(formatDate(job.finished_at))}</div>
|
||||
${job.install_url ? `<div>install: <a href="${esc(job.install_url)}" style="color:var(--accent)">${esc(job.install_url.slice(0, 80))}…</a></div>` : ''}
|
||||
${job.error ? `<div style="color:var(--danger)">error: ${esc(job.error)}</div>` : ''}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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 = '<div class="card"><p style="color:var(--text-muted)">No devices registered yet.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Name</th><th>UDID</th><th>Status</th><th>Added</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${devices.map(d => `
|
||||
<tr>
|
||||
<td>${esc(d.name) || '<span class="mono" style="color:var(--text-muted)">unnamed</span>'}</td>
|
||||
<td class="mono">${esc(d.udid)}</td>
|
||||
<td>${d.synced_at
|
||||
? '<span class="badge synced">Synced</span>'
|
||||
: '<span class="badge unsynced">Local only</span>'}</td>
|
||||
<td class="mono">${esc(d.added_at)}</td>
|
||||
<td><button class="delete-btn" data-udid="${esc(d.udid)}" title="Delete">×</button></td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
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();
|
||||
210
builder/public/js/profiles.js
Normal file
210
builder/public/js/profiles.js
Normal file
@@ -0,0 +1,210 @@
|
||||
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);
|
||||
}
|
||||
|
||||
function formatDate(s) {
|
||||
if (!s) return '--';
|
||||
return new Date(s).toLocaleDateString();
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const cls = status === 'valid' ? 'succeeded'
|
||||
: status === 'expiring' ? 'pending'
|
||||
: status === 'expired' ? 'failed'
|
||||
: 'pending';
|
||||
return `<span class="badge ${cls}">${status}</span>`;
|
||||
}
|
||||
|
||||
function methodBadge(method) {
|
||||
if (method === 'ad-hoc') return '<span class="badge running">ad-hoc</span>';
|
||||
if (method === 'development') return '<span class="badge pending">dev</span>';
|
||||
if (method === 'app-store') return '<span class="badge succeeded">app-store</span>';
|
||||
if (method === 'enterprise') return '<span class="badge succeeded">enterprise</span>';
|
||||
return `<span class="badge">${method || 'unknown'}</span>`;
|
||||
}
|
||||
|
||||
// --- Installed profiles ---
|
||||
|
||||
let installedProfiles = [];
|
||||
|
||||
async function loadProfiles() {
|
||||
const r = await fetch('/api/profiles');
|
||||
if (r.status === 401) { location.href = '/login'; return; }
|
||||
installedProfiles = await r.json();
|
||||
const container = $('#profiles-container');
|
||||
|
||||
if (!installedProfiles.length) {
|
||||
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No provisioning profiles installed. Generate one from the bundle IDs above.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show ad-hoc profiles
|
||||
const adhoc = installedProfiles.filter(p => p.method === 'ad-hoc');
|
||||
if (!adhoc.length) {
|
||||
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No ad-hoc profiles found. Other profile types are installed but not shown.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bundle ID</th>
|
||||
<th>Name</th>
|
||||
<th>Devices</th>
|
||||
<th>Expires</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${adhoc.map(p => `
|
||||
<tr>
|
||||
<td class="mono">${esc(p.bundleIdentifier || '--')}</td>
|
||||
<td style="font-size:13px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.name)}">${esc(p.name || '--')}</td>
|
||||
<td class="mono">${p.deviceCount ?? '--'}</td>
|
||||
<td class="mono">${esc(formatDate(p.expiresAt))}</td>
|
||||
<td>${statusBadge(p.status)}</td>
|
||||
<td style="white-space:nowrap">
|
||||
${p.bundleIdentifier ? `<button class="btn-sm btn-secondary regen-btn" data-bundle="${esc(p.bundleIdentifier)}" style="margin-right:4px">Regenerate</button>` : ''}
|
||||
<button class="btn-sm btn-danger delete-btn" data-uuid="${esc(p.uuid)}">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
bindProfileActions(container);
|
||||
}
|
||||
|
||||
function bindProfileActions(container) {
|
||||
container.querySelectorAll('.delete-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
if (!confirm('Delete this profile?')) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '...';
|
||||
try {
|
||||
const r = await fetch(`/api/profiles/${btn.dataset.uuid}`, { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error((await r.json()).error || 'Delete failed');
|
||||
toast('Profile deleted', 'success');
|
||||
loadProfiles();
|
||||
loadBundleIds();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Delete';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
container.querySelectorAll('.regen-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => generateProfile(btn.dataset.bundle, null, btn));
|
||||
});
|
||||
}
|
||||
|
||||
async function generateProfile(bundleId, teamId, btn) {
|
||||
if (!teamId) {
|
||||
// Regenerate button on the installed-profiles table doesn't know the team yet — look it up
|
||||
const installed = installedProfiles.find(p => p.bundleIdentifier === bundleId);
|
||||
teamId = installed?.teamId;
|
||||
}
|
||||
if (!teamId) {
|
||||
toast('No team ID available for this bundle. Generate from the ASC bundle list above.', 'error');
|
||||
return;
|
||||
}
|
||||
const origText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating...';
|
||||
try {
|
||||
const r = await fetch('/api/profiles/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bundleId, teamId }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).error || 'Generation failed');
|
||||
toast(`Profile generated for ${bundleId}`, 'success');
|
||||
loadProfiles();
|
||||
loadBundleIds();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = origText;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Bundle IDs from ASC ---
|
||||
|
||||
async function loadBundleIds() {
|
||||
const container = $('#bundle-ids-container');
|
||||
try {
|
||||
const r = await fetch('/api/bundle-ids');
|
||||
if (r.status === 401) { location.href = '/login'; return; }
|
||||
const groups = await r.json();
|
||||
|
||||
if (groups.error) throw new Error(groups.error);
|
||||
if (!Array.isArray(groups) || !groups.length) {
|
||||
container.innerHTML = '<div class="card"><p style="color:var(--text-muted)">No developer accounts configured. Add one in Settings.</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const installedBundles = new Set(
|
||||
installedProfiles
|
||||
.filter(p => p.method === 'ad-hoc')
|
||||
.map(p => p.bundleIdentifier)
|
||||
);
|
||||
|
||||
container.innerHTML = groups.map(g => {
|
||||
if (g.error) {
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<h3 style="margin-bottom:8px">${esc(g.teamName)} <span class="mono" style="color:var(--text-muted);font-size:12px">${esc(g.teamId)}</span></h3>
|
||||
<p style="color:var(--danger);font-size:13px">${esc(g.error)}</p>
|
||||
</div>`;
|
||||
}
|
||||
if (!g.bundleIds.length) {
|
||||
return `<div class="card" style="margin-bottom:16px">
|
||||
<h3 style="margin-bottom:8px">${esc(g.teamName)} <span class="mono" style="color:var(--text-muted);font-size:12px">${esc(g.teamId)}</span></h3>
|
||||
<p style="color:var(--text-muted);font-size:13px">No bundle IDs registered.</p>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="card" style="padding:0;overflow:hidden;margin-bottom:16px">
|
||||
<div style="padding:12px 20px;border-bottom:1px solid var(--border);background:var(--bg)">
|
||||
<h3 style="font-size:14px">${esc(g.teamName)} <span class="mono" style="color:var(--text-muted);font-size:12px;margin-left:6px">${esc(g.teamId)}</span></h3>
|
||||
</div>
|
||||
${g.bundleIds.map(b => {
|
||||
const hasProfile = installedBundles.has(b.identifier);
|
||||
return `<div class="bundle-id-row" style="display:flex;align-items:center;justify-content:space-between;padding:12px 20px;border-bottom:1px solid var(--border)">
|
||||
<div>
|
||||
<span class="mono" style="font-size:14px">${esc(b.identifier)}</span>
|
||||
<span style="color:var(--text-muted);font-size:12px;margin-left:8px">${esc(b.name)}</span>
|
||||
<span style="color:var(--text-muted);font-size:11px;margin-left:6px">${esc(b.platform)}</span>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
${hasProfile
|
||||
? '<span class="badge succeeded">has profile</span>'
|
||||
: `<button class="btn-sm gen-btn" data-bundle="${esc(b.identifier)}" data-team="${esc(g.teamId)}">Generate Ad-Hoc</button>`
|
||||
}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.gen-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => generateProfile(btn.dataset.bundle, btn.dataset.team, btn));
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="card"><p style="color:var(--danger)">${esc(err.message)}</p><p style="color:var(--text-muted);font-size:13px;margin-top:8px">Configure at least one Developer Account in Settings to fetch bundle IDs.</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Load both in parallel
|
||||
loadProfiles().then(() => loadBundleIds());
|
||||
@@ -6,23 +6,131 @@ const toast = (msg, kind = '') => {
|
||||
setTimeout(() => t.classList.remove('show'), 3000);
|
||||
};
|
||||
|
||||
async function load() {
|
||||
const escapeHtml = (str) => (str || '').replace(/[&<>"']/g, (c) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[c]));
|
||||
|
||||
async function loadStorefront() {
|
||||
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 || '']));
|
||||
async function loadKeys() {
|
||||
const res = await fetch('/api/asc-keys');
|
||||
if (res.status === 401) { location.href = '/login'; return; }
|
||||
const keys = await res.json();
|
||||
const container = $('#asc-keys-table');
|
||||
|
||||
if (!keys.length) {
|
||||
container.innerHTML = '<p style="color:var(--text-muted);margin:0">No developer accounts configured yet.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Team Name</th>
|
||||
<th>Team ID</th>
|
||||
<th>Key ID</th>
|
||||
<th>Issuer ID</th>
|
||||
<th>.p8</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${keys.map((k) => `
|
||||
<tr data-team-id="${escapeHtml(k.team_id)}">
|
||||
<td>${escapeHtml(k.team_name || '')}</td>
|
||||
<td><code>${escapeHtml(k.team_id)}</code></td>
|
||||
<td><code>${escapeHtml(k.key_id)}</code></td>
|
||||
<td><code style="font-size:11px">${escapeHtml(k.issuer_id)}</code></td>
|
||||
<td>${k.p8_uploaded ? '<span class="badge badge-valid">uploaded</span>' : '<span class="badge badge-expired">missing</span>'}</td>
|
||||
<td class="action-cell">
|
||||
<label class="btn-secondary btn-xs upload-label">
|
||||
<input type="file" accept=".p8" style="display:none" class="p8-input">
|
||||
Upload .p8
|
||||
</label>
|
||||
<button class="btn-secondary btn-xs test-btn">Test</button>
|
||||
<button class="btn-danger btn-xs delete-btn">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
// Wire row actions
|
||||
container.querySelectorAll('tr[data-team-id]').forEach((row) => {
|
||||
const teamId = row.dataset.teamId;
|
||||
row.querySelector('.p8-input').addEventListener('change', (e) => uploadP8(teamId, e.target.files[0]));
|
||||
row.querySelector('.test-btn').addEventListener('click', () => testKey(teamId));
|
||||
row.querySelector('.delete-btn').addEventListener('click', () => deleteKey(teamId));
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadP8(teamId, file) {
|
||||
if (!file) return;
|
||||
const fd = new FormData();
|
||||
fd.append('p8', file);
|
||||
const res = await fetch(`/api/asc-keys/${encodeURIComponent(teamId)}/p8`, { method: 'POST', body: fd });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
toast('.p8 uploaded', 'success');
|
||||
loadKeys();
|
||||
} else {
|
||||
toast(data.error || 'Upload failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testKey(teamId) {
|
||||
toast('Testing...', '');
|
||||
const res = await fetch(`/api/asc-keys/${encodeURIComponent(teamId)}/test`, { method: 'POST' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) toast(`Team ${teamId} authenticated OK`, 'success');
|
||||
else toast(data.error || 'Test failed', 'error');
|
||||
}
|
||||
|
||||
async function deleteKey(teamId) {
|
||||
if (!confirm(`Delete ASC key for team ${teamId}? The .p8 file will be removed from disk.`)) return;
|
||||
const res = await fetch(`/api/asc-keys/${encodeURIComponent(teamId)}`, { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
toast('Deleted', 'success');
|
||||
loadKeys();
|
||||
} else {
|
||||
toast(data.error || 'Delete failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
$('#add-key-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.target);
|
||||
const payload = Object.fromEntries(fd.entries());
|
||||
const res = await fetch('/api/asc-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
toast('Saved', 'success');
|
||||
e.target.reset();
|
||||
loadKeys();
|
||||
} else {
|
||||
toast(data.error || 'Save failed', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
$('#storefront-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const data = {
|
||||
unraid_url: e.target.querySelector('[name=unraid_url]').value,
|
||||
unraid_token: e.target.querySelector('[name=unraid_token]').value,
|
||||
};
|
||||
const res = await fetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -30,45 +138,14 @@ async function saveForm(formEl, keys) {
|
||||
});
|
||||
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' });
|
||||
$('#test-storefront').addEventListener('click', async () => {
|
||||
const res = await fetch('/api/settings/test-storefront', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (res.ok) toast(`Connected — ${data.device_count} devices in portal`, 'success');
|
||||
if (res.ok) toast(`Connected -- ${data.app_count} apps on storefront`, '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();
|
||||
loadStorefront();
|
||||
loadKeys();
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
// 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,
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
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) {
|
||||
@@ -10,18 +9,8 @@ function requireLogin(req, res, next) {
|
||||
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 };
|
||||
module.exports = { requireLogin, validatePassword };
|
||||
|
||||
@@ -1,71 +1,24 @@
|
||||
// Build pipeline HTTP routes.
|
||||
// Build pipeline + filesystem browser HTTP routes.
|
||||
// Attached to the main Express app in server.js via `register(app)`.
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { spawn } = require('child_process');
|
||||
const multer = require('multer');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
const { db, DATA_DIR } = require('./db');
|
||||
const buildWorker = require('./build-worker');
|
||||
|
||||
const SOURCE_DIR = path.join(DATA_DIR, 'source');
|
||||
const LOGS_DIR = path.join(DATA_DIR, 'builds');
|
||||
const TMP_DIR = path.join(DATA_DIR, 'tmp');
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
|
||||
[SOURCE_DIR, LOGS_DIR, TMP_DIR].forEach((d) => fs.mkdirSync(d, { recursive: true }));
|
||||
const DEFAULT_BROWSE_ROOT = path.join(os.homedir(), 'Desktop', 'code');
|
||||
|
||||
const archiveUpload = multer({
|
||||
dest: TMP_DIR,
|
||||
limits: { fileSize: 500 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const name = file.originalname.toLowerCase();
|
||||
if (name.endsWith('.zip') || name.endsWith('.tar.gz') || name.endsWith('.tgz')) {
|
||||
return cb(null, true);
|
||||
}
|
||||
cb(new Error('Only .zip, .tar.gz, or .tgz archives'));
|
||||
},
|
||||
});
|
||||
|
||||
function extractArchive(archivePath, destDir) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const lower = archivePath.toLowerCase();
|
||||
let cmd, args;
|
||||
if (lower.endsWith('.zip')) {
|
||||
cmd = '/usr/bin/unzip';
|
||||
args = ['-q', archivePath, '-d', destDir];
|
||||
} else {
|
||||
cmd = '/usr/bin/tar';
|
||||
args = ['-xzf', archivePath, '-C', destDir];
|
||||
}
|
||||
const child = spawn(cmd, args);
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (c) => { stderr += c.toString(); });
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`${cmd} exited ${code}: ${stderr}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function cloneGitRepo({ url, branch, destDir, logPath }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
const args = ['clone', '--depth', '1'];
|
||||
if (branch) args.push('--branch', branch);
|
||||
args.push(url, destDir);
|
||||
fs.appendFileSync(logPath, `$ git ${args.join(' ')}\n`);
|
||||
const child = spawn('/usr/bin/git', args);
|
||||
child.stdout.on('data', (c) => fs.appendFileSync(logPath, c));
|
||||
child.stderr.on('data', (c) => fs.appendFileSync(logPath, c));
|
||||
child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`git clone failed (${code})`)));
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
// Directories to skip when browsing
|
||||
const SKIP_DIRS = new Set([
|
||||
'node_modules', 'Pods', 'build', 'DerivedData',
|
||||
'.build', '.git', '__MACOSX', 'Carthage',
|
||||
]);
|
||||
|
||||
function register(app, { requireLogin }) {
|
||||
// --- Pages ---
|
||||
@@ -76,60 +29,116 @@ function register(app, { requireLogin }) {
|
||||
res.sendFile(path.join(__dirname, '..', 'views', 'builds.html'));
|
||||
});
|
||||
|
||||
// --- Upload a source archive ---
|
||||
app.post('/api/build/upload', requireLogin, archiveUpload.single('source'), async (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: 'No source file provided' });
|
||||
// --- Filesystem browser ---
|
||||
|
||||
app.get('/api/filesystem/browse', requireLogin, (req, res) => {
|
||||
const targetPath = req.query.path || DEFAULT_BROWSE_ROOT;
|
||||
|
||||
// Block path traversal
|
||||
if (targetPath.includes('..')) {
|
||||
return res.status(400).json({ error: 'Path traversal not allowed' });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return res.status(404).json({ error: 'Path not found' });
|
||||
}
|
||||
|
||||
const stat = fs.statSync(targetPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return res.status(400).json({ error: 'Path is not a directory' });
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
const jobId = uuidv4();
|
||||
const sourceDir = path.join(SOURCE_DIR, jobId);
|
||||
await extractArchive(req.file.path, sourceDir);
|
||||
fs.unlinkSync(req.file.path);
|
||||
|
||||
const scheme = (req.body.scheme || '').trim() || null;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO build_jobs (id, source_kind, source_ref, scheme, status)
|
||||
VALUES (?, 'upload', ?, ?, 'pending')
|
||||
`).run(jobId, req.file.originalname, scheme);
|
||||
|
||||
buildWorker.kick();
|
||||
res.json({ success: true, job_id: jobId });
|
||||
entries = fs.readdirSync(targetPath, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
return res.status(403).json({ error: `Cannot read directory: ${err.message}` });
|
||||
}
|
||||
|
||||
const result = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name.endsWith('.xcodeproj')) {
|
||||
result.push({ name: entry.name, type: 'xcodeproj', path: path.join(targetPath, entry.name) });
|
||||
} else if (entry.name.endsWith('.xcworkspace')) {
|
||||
result.push({ name: entry.name, type: 'xcworkspace', path: path.join(targetPath, entry.name) });
|
||||
} else {
|
||||
result.push({ name: entry.name, type: 'directory', path: path.join(targetPath, entry.name) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: Xcode projects first, then directories alphabetically
|
||||
result.sort((a, b) => {
|
||||
const aIsXcode = a.type === 'xcodeproj' || a.type === 'xcworkspace';
|
||||
const bIsXcode = b.type === 'xcodeproj' || b.type === 'xcworkspace';
|
||||
if (aIsXcode && !bIsXcode) return -1;
|
||||
if (!aIsXcode && bIsXcode) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
res.json({
|
||||
path: targetPath,
|
||||
parent: path.dirname(targetPath),
|
||||
entries: result,
|
||||
});
|
||||
});
|
||||
|
||||
// --- List schemes for a project ---
|
||||
|
||||
app.get('/api/filesystem/schemes', requireLogin, async (req, res) => {
|
||||
const projectPath = req.query.projectPath;
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ error: 'projectPath is required' });
|
||||
}
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
if (!projectPath.endsWith('.xcodeproj') && !projectPath.endsWith('.xcworkspace')) {
|
||||
return res.status(400).json({ error: 'Not an Xcode project or workspace' });
|
||||
}
|
||||
|
||||
try {
|
||||
const schemes = await buildWorker.listSchemesForPath(projectPath);
|
||||
res.json({ schemes });
|
||||
} catch (err) {
|
||||
if (req.file && fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Clone a git repo ---
|
||||
app.post('/api/build/git', requireLogin, async (req, res) => {
|
||||
const { url, branch, scheme } = req.body || {};
|
||||
if (!url) return res.status(400).json({ error: 'url is required' });
|
||||
// --- Trigger a local build ---
|
||||
|
||||
app.post('/api/build/local', requireLogin, (req, res) => {
|
||||
const { projectPath, scheme } = req.body || {};
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ error: 'projectPath is required' });
|
||||
}
|
||||
if (!fs.existsSync(projectPath)) {
|
||||
return res.status(404).json({ error: 'Project not found' });
|
||||
}
|
||||
if (!projectPath.endsWith('.xcodeproj') && !projectPath.endsWith('.xcworkspace')) {
|
||||
return res.status(400).json({ error: 'Not an Xcode project or workspace' });
|
||||
}
|
||||
|
||||
const jobId = uuidv4();
|
||||
const logPath = path.join(LOGS_DIR, `${jobId}.log`);
|
||||
fs.writeFileSync(logPath, `Cloning ${url}${branch ? ` (branch ${branch})` : ''}…\n`);
|
||||
const projectName = path.basename(projectPath);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO build_jobs (id, source_kind, source_ref, scheme, status, log_path)
|
||||
VALUES (?, 'git', ?, ?, 'pending', ?)
|
||||
`).run(jobId, url, scheme || null, logPath);
|
||||
INSERT INTO build_jobs (id, project_path, source_ref, scheme, status)
|
||||
VALUES (?, ?, ?, ?, 'pending')
|
||||
`).run(jobId, projectPath, projectName, scheme || null);
|
||||
|
||||
try {
|
||||
const sourceDir = path.join(SOURCE_DIR, jobId);
|
||||
await cloneGitRepo({ url, branch, destDir: sourceDir, logPath });
|
||||
buildWorker.kick();
|
||||
res.json({ success: true, job_id: jobId });
|
||||
} catch (err) {
|
||||
db.prepare("UPDATE build_jobs SET status = 'failed', error = ?, finished_at = datetime('now') WHERE id = ?")
|
||||
.run(err.message, jobId);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
buildWorker.kick();
|
||||
res.json({ success: true, job_id: jobId });
|
||||
});
|
||||
|
||||
// --- List jobs ---
|
||||
app.get('/api/builds', requireLogin, (req, res) => {
|
||||
const rows = db.prepare(`
|
||||
SELECT id, bundle_id, source_kind, source_ref, scheme, status, started_at, finished_at, error, unraid_build_id, install_url
|
||||
SELECT id, bundle_id, project_path, source_ref, scheme, status, started_at, finished_at, error, unraid_build_id, install_url
|
||||
FROM build_jobs
|
||||
ORDER BY COALESCE(started_at, created_at) DESC
|
||||
LIMIT 100
|
||||
@@ -167,7 +176,6 @@ function register(app, { requireLogin }) {
|
||||
fs.readSync(fd, buf, 0, buf.length, position);
|
||||
fs.closeSync(fd);
|
||||
position = stat.size;
|
||||
// SSE: prefix every line with "data: "
|
||||
const lines = buf.toString('utf8').split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.length) res.write(`data: ${line}\n\n`);
|
||||
@@ -177,7 +185,6 @@ function register(app, { requireLogin }) {
|
||||
sendNew();
|
||||
const interval = setInterval(() => {
|
||||
sendNew();
|
||||
// Check if job finished — send one more time and close after a grace period.
|
||||
const current = db.prepare('SELECT status FROM build_jobs WHERE id = ?').get(req.params.id);
|
||||
if (current && (current.status === 'succeeded' || current.status === 'failed')) {
|
||||
sendNew();
|
||||
@@ -189,11 +196,6 @@ function register(app, { requireLogin }) {
|
||||
|
||||
req.on('close', () => clearInterval(interval));
|
||||
});
|
||||
|
||||
// --- Rebuild a finished job (reuses the last known source if available) ---
|
||||
app.post('/api/builds/:id/rebuild', requireLogin, (req, res) => {
|
||||
res.status(501).json({ error: 'rebuild not implemented yet' });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { register };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Build worker — consumes `build_jobs` rows, runs xcodebuild + fastlane + upload.
|
||||
// Build worker — consumes `build_jobs` rows, runs xcodebuild + upload.
|
||||
// Single in-process loop; SQLite is the queue.
|
||||
|
||||
const path = require('path');
|
||||
@@ -11,11 +11,10 @@ const execFileAsync = promisify(execFile);
|
||||
const { db, getSetting, DATA_DIR } = require('./db');
|
||||
const profileManager = require('./profile-manager');
|
||||
|
||||
const SOURCE_DIR = path.join(DATA_DIR, 'source');
|
||||
const BUILD_DIR = path.join(DATA_DIR, 'build');
|
||||
const LOGS_DIR = path.join(DATA_DIR, 'builds');
|
||||
|
||||
[SOURCE_DIR, BUILD_DIR, LOGS_DIR].forEach((d) => fs.mkdirSync(d, { recursive: true }));
|
||||
[BUILD_DIR, LOGS_DIR].forEach((d) => fs.mkdirSync(d, { recursive: true }));
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
let running = false;
|
||||
@@ -62,28 +61,29 @@ function runCommand(cmd, args, { cwd, env, logPath }) {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Project locator ---
|
||||
// --- Scheme listing (standalone, no log file required) ---
|
||||
|
||||
function findProjectRoot(sourceDir) {
|
||||
// Walk up to 3 levels looking for .xcodeproj/.xcworkspace.
|
||||
const walk = (dir, depth) => {
|
||||
if (depth > 3) return null;
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const workspace = entries.find((e) => e.isDirectory() && e.name.endsWith('.xcworkspace') && !e.name.endsWith('.xcodeproj/project.xcworkspace'));
|
||||
if (workspace) return { dir, type: 'workspace', name: workspace.name };
|
||||
const project = entries.find((e) => e.isDirectory() && e.name.endsWith('.xcodeproj'));
|
||||
if (project) return { dir, type: 'project', name: project.name };
|
||||
for (const e of entries) {
|
||||
if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') {
|
||||
const found = walk(path.join(dir, e.name), depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(sourceDir, 0);
|
||||
async function listSchemesForPath(projectPath) {
|
||||
const projectName = path.basename(projectPath);
|
||||
const projectDir = path.dirname(projectPath);
|
||||
const type = projectPath.endsWith('.xcworkspace') ? 'workspace' : 'project';
|
||||
const flag = type === 'workspace' ? '-workspace' : '-project';
|
||||
|
||||
const { stdout } = await execFileAsync('/usr/bin/xcodebuild', ['-list', '-json', flag, projectName], {
|
||||
cwd: projectDir,
|
||||
env: process.env,
|
||||
timeout: 60000,
|
||||
});
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
return parsed.workspace?.schemes || parsed.project?.schemes || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Build helpers ---
|
||||
|
||||
async function listSchemes({ projectRoot, logPath }) {
|
||||
const args = projectRoot.type === 'workspace'
|
||||
? ['-list', '-json', '-workspace', projectRoot.name]
|
||||
@@ -98,7 +98,7 @@ async function listSchemes({ projectRoot, logPath }) {
|
||||
}
|
||||
|
||||
async function getBuildSettings({ projectRoot, scheme, logPath }) {
|
||||
const args = ['-showBuildSettings', '-json', '-scheme', scheme];
|
||||
const args = ['-showBuildSettings', '-json', '-scheme', scheme, '-configuration', 'Release'];
|
||||
if (projectRoot.type === 'workspace') args.unshift('-workspace', projectRoot.name);
|
||||
else args.unshift('-project', projectRoot.name);
|
||||
const { stdout } = await runCommand('/usr/bin/xcodebuild', args, { cwd: projectRoot.dir, env: process.env, logPath });
|
||||
@@ -144,14 +144,14 @@ ${entries}
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Upload to unraid ---
|
||||
// --- Upload to storefront ---
|
||||
|
||||
async function uploadToUnraid({ ipaPath, notes, logPath }) {
|
||||
const unraidUrl = getSetting('unraid_url');
|
||||
const unraidToken = getSetting('unraid_token');
|
||||
if (!unraidUrl || !unraidToken) throw new Error('unraid URL/token not configured');
|
||||
async function uploadToStorefront({ ipaPath, notes, logPath }) {
|
||||
const storefrontUrl = getSetting('unraid_url');
|
||||
const storefrontToken = getSetting('unraid_token');
|
||||
if (!storefrontUrl || !storefrontToken) throw new Error('Storefront URL/token not configured');
|
||||
|
||||
appendLog(logPath, `\nUploading IPA to ${unraidUrl}/api/upload`);
|
||||
appendLog(logPath, `\nUploading IPA to ${storefrontUrl}/api/upload`);
|
||||
|
||||
const buf = fs.readFileSync(ipaPath);
|
||||
const blob = new Blob([buf], { type: 'application/octet-stream' });
|
||||
@@ -159,16 +159,16 @@ async function uploadToUnraid({ ipaPath, notes, logPath }) {
|
||||
form.append('ipa', blob, path.basename(ipaPath));
|
||||
if (notes) form.append('notes', notes);
|
||||
|
||||
const res = await fetch(`${unraidUrl}/api/upload`, {
|
||||
const res = await fetch(`${storefrontUrl}/api/upload`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Api-Token': unraidToken },
|
||||
headers: { 'X-Api-Token': storefrontToken },
|
||||
body: form,
|
||||
});
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !body.success) {
|
||||
throw new Error(`unraid upload failed (${res.status}): ${JSON.stringify(body)}`);
|
||||
throw new Error(`Storefront upload failed (${res.status}): ${JSON.stringify(body)}`);
|
||||
}
|
||||
appendLog(logPath, `✓ Uploaded: ${JSON.stringify(body)}`);
|
||||
appendLog(logPath, `Uploaded: ${JSON.stringify(body)}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
@@ -177,19 +177,25 @@ async function uploadToUnraid({ ipaPath, notes, logPath }) {
|
||||
async function runBuild(job) {
|
||||
const jobId = job.id;
|
||||
const logPath = path.join(LOGS_DIR, `${jobId}.log`);
|
||||
markStatus(jobId, 'preparing', { log_path: logPath, started_at: "datetime('now')" });
|
||||
// Reset started_at properly (datetime() doesn't work via binding above).
|
||||
markStatus(jobId, 'preparing', { log_path: logPath });
|
||||
db.prepare("UPDATE build_jobs SET started_at = datetime('now') WHERE id = ?").run(jobId);
|
||||
|
||||
fs.writeFileSync(logPath, `Build ${jobId} started at ${new Date().toISOString()}\n`);
|
||||
section(logPath, 'PREPARING');
|
||||
|
||||
const sourceDir = path.join(SOURCE_DIR, jobId);
|
||||
if (!fs.existsSync(sourceDir)) throw new Error(`source dir missing: ${sourceDir}`);
|
||||
// Resolve the local project path
|
||||
const projectPath = job.project_path;
|
||||
if (!projectPath || !fs.existsSync(projectPath)) {
|
||||
throw new Error(`Project not found: ${projectPath}`);
|
||||
}
|
||||
|
||||
const projectRoot = findProjectRoot(sourceDir);
|
||||
if (!projectRoot) throw new Error('No .xcodeproj or .xcworkspace found in source');
|
||||
appendLog(logPath, `Found ${projectRoot.type}: ${projectRoot.name} in ${projectRoot.dir}`);
|
||||
const projectName = path.basename(projectPath);
|
||||
const projectDir = path.dirname(projectPath);
|
||||
const type = projectPath.endsWith('.xcworkspace') ? 'workspace' : 'project';
|
||||
const projectRoot = { dir: projectDir, type, name: projectName };
|
||||
|
||||
appendLog(logPath, `Project: ${projectPath}`);
|
||||
appendLog(logPath, `Type: ${type}, Name: ${projectName}`);
|
||||
|
||||
// Pick the scheme
|
||||
const schemes = await listSchemes({ projectRoot, logPath });
|
||||
@@ -207,7 +213,6 @@ async function runBuild(job) {
|
||||
appendLog(logPath, `Team: ${teamId}`);
|
||||
appendLog(logPath, `Bundle IDs: ${bundleIds.join(', ')}`);
|
||||
|
||||
// Persist the primary bundle id on the job row
|
||||
db.prepare('UPDATE build_jobs SET bundle_id = ?, scheme = ? WHERE id = ?')
|
||||
.run(bundleIds[0], scheme, jobId);
|
||||
|
||||
@@ -215,12 +220,17 @@ async function runBuild(job) {
|
||||
section(logPath, 'SIGNING');
|
||||
markStatus(jobId, 'signing');
|
||||
|
||||
if (!teamId) {
|
||||
throw new Error('Could not determine DEVELOPMENT_TEAM from Xcode project');
|
||||
}
|
||||
|
||||
const profilesByBundleId = {};
|
||||
for (const bid of bundleIds) {
|
||||
appendLog(logPath, `Ensuring profile for ${bid}…`);
|
||||
const info = await profileManager.getProfile(bid);
|
||||
appendLog(logPath, `Ensuring profile for ${bid} (team ${teamId})...`);
|
||||
const info = await profileManager.getProfile(bid, { teamId });
|
||||
profilesByBundleId[bid] = info.profile_uuid;
|
||||
appendLog(logPath, ` → ${info.profile_uuid} (${info.fromCache ? 'cache' : 'fresh'}, ${info.device_count} devices, expires ${info.expires_at})`);
|
||||
const source = info.fromCache ? 'cache' : info.fromDisk ? 'disk' : 'fastlane';
|
||||
appendLog(logPath, ` -> ${info.profile_uuid} (${source}, ${info.device_count} devices, expires ${info.expires_at})`);
|
||||
}
|
||||
|
||||
// --- Archiving phase ---
|
||||
@@ -235,13 +245,9 @@ async function runBuild(job) {
|
||||
'-destination', 'generic/platform=iOS',
|
||||
'-archivePath', archivePath,
|
||||
'-allowProvisioningUpdates',
|
||||
'CODE_SIGN_STYLE=Manual',
|
||||
`DEVELOPMENT_TEAM=${teamId}`,
|
||||
'archive',
|
||||
];
|
||||
// We can't specify per-target PROVISIONING_PROFILE_SPECIFIER globally, so we rely on
|
||||
// xcodebuild finding the installed profiles in ~/Library/MobileDevice/Provisioning Profiles/
|
||||
// by matching bundle id + team id.
|
||||
await runCommand('/usr/bin/xcodebuild', archiveArgs, { cwd: projectRoot.dir, env: process.env, logPath });
|
||||
|
||||
// --- Exporting phase ---
|
||||
@@ -274,9 +280,9 @@ async function runBuild(job) {
|
||||
section(logPath, 'UPLOADING');
|
||||
markStatus(jobId, 'uploading', { ipa_path: ipaPath });
|
||||
|
||||
const uploadResult = await uploadToUnraid({
|
||||
const uploadResult = await uploadToStorefront({
|
||||
ipaPath,
|
||||
notes: `Built by ${os.hostname()} job ${jobId}`,
|
||||
notes: `Built from ${projectName} by ${os.hostname()} job ${jobId}`,
|
||||
logPath,
|
||||
});
|
||||
|
||||
@@ -285,9 +291,8 @@ async function runBuild(job) {
|
||||
install_url: uploadResult.build?.install_url || null,
|
||||
});
|
||||
|
||||
// --- Cleanup: keep log + IPA, remove source + archive ---
|
||||
// Cleanup: remove archive only (keep IPA, never touch user's project)
|
||||
try {
|
||||
fs.rmSync(sourceDir, { recursive: true, force: true });
|
||||
fs.rmSync(archivePath, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
appendLog(logPath, `Cleanup warning: ${e.message}`);
|
||||
@@ -302,7 +307,7 @@ async function processJob(job) {
|
||||
await runBuild(job);
|
||||
} catch (err) {
|
||||
console.error(`[build-worker] job ${job.id} failed:`, err);
|
||||
try { appendLog(logPath, `\n✗ FAILED: ${err.message}\n${err.stack || ''}`); } catch {}
|
||||
try { appendLog(logPath, `\nFAILED: ${err.message}\n${err.stack || ''}`); } catch {}
|
||||
markStatus(job.id, 'failed', { error: err.message });
|
||||
}
|
||||
}
|
||||
@@ -322,7 +327,6 @@ async function loop() {
|
||||
}
|
||||
|
||||
function kick() {
|
||||
// Non-blocking: fire and forget.
|
||||
loop().catch((err) => console.error('[build-worker] loop error:', err));
|
||||
}
|
||||
|
||||
@@ -331,4 +335,4 @@ function start() {
|
||||
kick();
|
||||
}
|
||||
|
||||
module.exports = { start, kick, runBuild, processJob };
|
||||
module.exports = { start, kick, listSchemesForPath };
|
||||
|
||||
@@ -15,26 +15,6 @@ db.exec(`
|
||||
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,
|
||||
@@ -42,15 +22,15 @@ db.exec(`
|
||||
team_id TEXT,
|
||||
expires_at TEXT,
|
||||
device_count INTEGER,
|
||||
method TEXT DEFAULT 'ad-hoc',
|
||||
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,
|
||||
project_path TEXT,
|
||||
source_ref TEXT,
|
||||
scheme TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
@@ -63,8 +43,38 @@ db.exec(`
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS asc_keys (
|
||||
team_id TEXT PRIMARY KEY,
|
||||
team_name TEXT,
|
||||
key_id TEXT NOT NULL,
|
||||
issuer_id TEXT NOT NULL,
|
||||
p8_filename TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
|
||||
// Idempotent migrations for existing databases
|
||||
try { db.exec("ALTER TABLE build_jobs ADD COLUMN project_path TEXT"); } catch {}
|
||||
try { db.exec("ALTER TABLE profiles ADD COLUMN method TEXT DEFAULT 'ad-hoc'"); } catch {}
|
||||
|
||||
// Backfill asc_keys from legacy single-key settings (one-shot on first run after upgrade)
|
||||
try {
|
||||
const existing = db.prepare('SELECT COUNT(*) AS c FROM asc_keys').get();
|
||||
if (existing.c === 0) {
|
||||
const keyId = db.prepare("SELECT value FROM settings WHERE key = 'asc_key_id'").get()?.value;
|
||||
const issuerId = db.prepare("SELECT value FROM settings WHERE key = 'asc_issuer_id'").get()?.value;
|
||||
if (keyId && issuerId) {
|
||||
const p8Path = path.join(DATA_DIR, 'asc', `${keyId}.p8`);
|
||||
const p8Filename = fs.existsSync(p8Path) ? `${keyId}.p8` : null;
|
||||
db.prepare(`
|
||||
INSERT INTO asc_keys (team_id, team_name, key_id, issuer_id, p8_filename)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run('QND55P4443', 'Legacy', keyId, issuerId, p8Filename);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
function getSetting(key) {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||
return row ? row.value : null;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// 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/.
|
||||
// Profile manager: finds existing ad-hoc provisioning profiles on disk,
|
||||
// or generates new ones via `fastlane sigh`. Caches results in SQLite.
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const { db, getSetting, DATA_DIR } = require('./db');
|
||||
const { db, DATA_DIR } = require('./db');
|
||||
|
||||
const PROFILES_DIR = path.join(DATA_DIR, 'profiles');
|
||||
const ASC_DIR = path.join(DATA_DIR, 'asc');
|
||||
@@ -18,20 +18,31 @@ const INSTALLED_PROFILES_DIR = path.join(os.homedir(), 'Library/MobileDevice/Pro
|
||||
|
||||
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`);
|
||||
// --- ASC key resolution (per-team) ---
|
||||
|
||||
function listAscKeys() {
|
||||
return db.prepare('SELECT * FROM asc_keys ORDER BY created_at').all();
|
||||
}
|
||||
|
||||
function getAscKey(teamId) {
|
||||
if (!teamId) throw new Error('teamId is required');
|
||||
const row = db.prepare('SELECT * FROM asc_keys WHERE team_id = ?').get(teamId);
|
||||
if (!row) throw new Error(`No ASC key configured for team ${teamId}. Add it on the Settings page.`);
|
||||
if (!row.p8_filename) throw new Error(`ASC key for team ${teamId} exists but .p8 has not been uploaded.`);
|
||||
const p8Path = path.join(ASC_DIR, row.p8_filename);
|
||||
if (!fs.existsSync(p8Path)) throw new Error(`.p8 file missing on disk for team ${teamId}: ${row.p8_filename}`);
|
||||
return { ...row, p8Path };
|
||||
}
|
||||
|
||||
function buildAscKeyJsonPath(teamId) {
|
||||
const key = getAscKey(teamId);
|
||||
const keyContent = fs.readFileSync(key.p8Path, 'utf8');
|
||||
const jsonPath = path.join(ASC_DIR, `${key.key_id}.json`);
|
||||
const json = {
|
||||
key_id: keyId,
|
||||
issuer_id: issuerId,
|
||||
key_id: key.key_id,
|
||||
issuer_id: key.issuer_id,
|
||||
key: keyContent,
|
||||
duration: 1200,
|
||||
in_house: false,
|
||||
@@ -40,9 +51,9 @@ function buildAscKeyJsonPath() {
|
||||
return jsonPath;
|
||||
}
|
||||
|
||||
// --- Profile parser ---
|
||||
|
||||
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 {
|
||||
@@ -74,11 +85,30 @@ function parseMobileprovision(filePath) {
|
||||
|| (xml.match(/<key>TeamIdentifier<\/key>\s*<array>\s*<string>([^<]+)<\/string>/)?.[1] ?? null);
|
||||
const expiresAt = pickDate('ExpirationDate');
|
||||
|
||||
// Devices count from the ProvisionedDevices array.
|
||||
// Device count from the ProvisionedDevices array
|
||||
const devicesMatch = xml.match(/<key>ProvisionedDevices<\/key>\s*<array>([\s\S]*?)<\/array>/);
|
||||
const deviceCount = devicesMatch ? (devicesMatch[1].match(/<string>/g) || []).length : 0;
|
||||
|
||||
return { uuid, name, teamId, expiresAt, deviceCount };
|
||||
// Distribution method detection
|
||||
const hasDevices = xml.includes('<key>ProvisionedDevices</key>');
|
||||
const provisionsAll = xml.includes('<key>ProvisionsAllDevices</key>');
|
||||
const getTaskAllow = xml.includes('<key>get-task-allow</key>')
|
||||
&& /<key>get-task-allow<\/key>\s*<true\s*\/>/.test(xml);
|
||||
|
||||
let method;
|
||||
if (provisionsAll) method = 'enterprise';
|
||||
else if (hasDevices && !getTaskAllow) method = 'ad-hoc';
|
||||
else if (hasDevices && getTaskAllow) method = 'development';
|
||||
else method = 'app-store';
|
||||
|
||||
// Bundle ID from application-identifier entitlement
|
||||
const appIdMatch = xml.match(/<key>application-identifier<\/key>\s*<string>([^<]+)<\/string>/);
|
||||
const applicationIdentifier = appIdMatch ? appIdMatch[1] : null;
|
||||
const bundleIdentifier = applicationIdentifier
|
||||
? applicationIdentifier.replace(/^[A-Z0-9]+\./, '')
|
||||
: null;
|
||||
|
||||
return { uuid, name, teamId, expiresAt, deviceCount, method, bundleIdentifier, filePath };
|
||||
}
|
||||
|
||||
function installProfile(srcPath, uuid) {
|
||||
@@ -88,6 +118,68 @@ function installProfile(srcPath, uuid) {
|
||||
return dest;
|
||||
}
|
||||
|
||||
// --- Scan installed profiles ---
|
||||
|
||||
function scanInstalledProfiles(bundleId) {
|
||||
if (!fs.existsSync(INSTALLED_PROFILES_DIR)) return [];
|
||||
|
||||
const files = fs.readdirSync(INSTALLED_PROFILES_DIR)
|
||||
.filter((f) => f.endsWith('.mobileprovision'));
|
||||
|
||||
const matches = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(INSTALLED_PROFILES_DIR, file);
|
||||
const info = parseMobileprovision(filePath);
|
||||
if (info.method !== 'ad-hoc') continue;
|
||||
|
||||
// Match bundle ID: exact match or wildcard (e.g., "TEAM.*" matches anything)
|
||||
if (info.bundleIdentifier === bundleId || info.bundleIdentifier === '*') {
|
||||
// Check not expired
|
||||
if (info.expiresAt) {
|
||||
const expiresMs = Date.parse(info.expiresAt);
|
||||
if (!Number.isNaN(expiresMs) && expiresMs > Date.now()) {
|
||||
matches.push(info);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable profiles
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by expiry date descending (longest remaining validity first)
|
||||
matches.sort((a, b) => {
|
||||
const aExp = Date.parse(a.expiresAt) || 0;
|
||||
const bExp = Date.parse(b.expiresAt) || 0;
|
||||
return bExp - aExp;
|
||||
});
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function scanAllInstalledProfiles() {
|
||||
if (!fs.existsSync(INSTALLED_PROFILES_DIR)) return [];
|
||||
|
||||
const files = fs.readdirSync(INSTALLED_PROFILES_DIR)
|
||||
.filter((f) => f.endsWith('.mobileprovision'));
|
||||
|
||||
const profiles = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(INSTALLED_PROFILES_DIR, file);
|
||||
const info = parseMobileprovision(filePath);
|
||||
profiles.push(info);
|
||||
} catch {
|
||||
// Skip unparseable
|
||||
}
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
// --- DB cache helpers ---
|
||||
|
||||
function cachedRow(bundleId) {
|
||||
return db.prepare('SELECT * FROM profiles WHERE bundle_id = ?').get(bundleId);
|
||||
}
|
||||
@@ -101,13 +193,41 @@ function isCacheFresh(row) {
|
||||
return daysLeft >= MIN_LIFETIME_DAYS;
|
||||
}
|
||||
|
||||
async function runFastlaneSigh({ bundleId, outputPath, apiKeyJson, logStream }) {
|
||||
function upsertProfileCache(bundleId, info) {
|
||||
db.prepare(`
|
||||
INSERT INTO profiles (bundle_id, profile_uuid, name, team_id, expires_at, device_count, method, 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,
|
||||
method = excluded.method,
|
||||
path = excluded.path,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(
|
||||
bundleId,
|
||||
info.uuid,
|
||||
info.name,
|
||||
info.teamId,
|
||||
info.expiresAt,
|
||||
info.deviceCount,
|
||||
info.method || 'ad-hoc',
|
||||
info.filePath,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Fastlane sigh ---
|
||||
|
||||
async function runFastlaneSigh({ bundleId, teamId, outputPath, apiKeyJson, logStream }) {
|
||||
const args = [
|
||||
'run',
|
||||
'sigh',
|
||||
`adhoc:true`,
|
||||
`force:true`,
|
||||
`app_identifier:${bundleId}`,
|
||||
`team_id:${teamId}`,
|
||||
`api_key_path:${apiKeyJson}`,
|
||||
`output_path:${outputPath}`,
|
||||
`skip_install:true`,
|
||||
@@ -137,23 +257,52 @@ async function runFastlaneSigh({ bundleId, outputPath, apiKeyJson, logStream })
|
||||
});
|
||||
}
|
||||
|
||||
async function getProfile(bundleId, { force = false, logStream = null } = {}) {
|
||||
if (!bundleId) throw new Error('bundleId is required');
|
||||
// --- Main profile resolution ---
|
||||
|
||||
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 };
|
||||
async function getProfile(bundleId, { teamId, force = false, logStream = null } = {}) {
|
||||
if (!bundleId) throw new Error('bundleId is required');
|
||||
if (!teamId) throw new Error('teamId is required');
|
||||
|
||||
// 1. Check DB cache
|
||||
if (!force) {
|
||||
const existing = cachedRow(bundleId);
|
||||
if (isCacheFresh(existing)) {
|
||||
try { installProfile(existing.path, existing.profile_uuid); } catch {}
|
||||
return { ...existing, fromCache: true };
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeyJson = buildAscKeyJsonPath();
|
||||
// 2. Scan installed profiles on disk
|
||||
if (!force) {
|
||||
const installed = scanInstalledProfiles(bundleId);
|
||||
if (installed.length > 0) {
|
||||
const best = installed[0];
|
||||
const daysLeft = (Date.parse(best.expiresAt) - Date.now()) / (1000 * 60 * 60 * 24);
|
||||
if (daysLeft >= MIN_LIFETIME_DAYS) {
|
||||
upsertProfileCache(bundleId, best);
|
||||
return {
|
||||
bundle_id: bundleId,
|
||||
profile_uuid: best.uuid,
|
||||
name: best.name,
|
||||
team_id: best.teamId,
|
||||
expires_at: best.expiresAt,
|
||||
device_count: best.deviceCount,
|
||||
method: best.method,
|
||||
path: best.filePath,
|
||||
fromCache: false,
|
||||
fromDisk: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Generate via fastlane sigh
|
||||
const apiKeyJson = buildAscKeyJsonPath(teamId);
|
||||
const outputPath = path.join(PROFILES_DIR, bundleId);
|
||||
fs.mkdirSync(outputPath, { recursive: true });
|
||||
|
||||
await runFastlaneSigh({ bundleId, outputPath, apiKeyJson, logStream });
|
||||
await runFastlaneSigh({ bundleId, teamId, outputPath, apiKeyJson, logStream });
|
||||
|
||||
// Find the .mobileprovision fastlane produced.
|
||||
const candidates = fs.readdirSync(outputPath)
|
||||
.filter((f) => f.endsWith('.mobileprovision'))
|
||||
.map((f) => ({
|
||||
@@ -171,33 +320,13 @@ async function getProfile(bundleId, { force = false, logStream = null } = {}) {
|
||||
const parsed = parseMobileprovision(produced.path);
|
||||
if (!parsed.uuid) throw new Error('Could not parse UUID from produced profile');
|
||||
|
||||
// Normalize storage: rename to <uuid>.mobileprovision inside the per-bundle dir.
|
||||
const finalPath = path.join(outputPath, `${parsed.uuid}.mobileprovision`);
|
||||
if (produced.path !== finalPath) {
|
||||
fs.renameSync(produced.path, finalPath);
|
||||
}
|
||||
parsed.filePath = 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,
|
||||
);
|
||||
|
||||
upsertProfileCache(bundleId, parsed);
|
||||
installProfile(finalPath, parsed.uuid);
|
||||
|
||||
return {
|
||||
@@ -207,9 +336,132 @@ async function getProfile(bundleId, { force = false, logStream = null } = {}) {
|
||||
team_id: parsed.teamId,
|
||||
expires_at: parsed.expiresAt,
|
||||
device_count: parsed.deviceCount,
|
||||
method: parsed.method,
|
||||
path: finalPath,
|
||||
fromCache: false,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getProfile, parseMobileprovision };
|
||||
// --- ASC API: fetch registered bundle IDs ---
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf).toString('base64url');
|
||||
}
|
||||
|
||||
const jwtCache = new Map(); // teamId -> { jwt, exp }
|
||||
|
||||
function signAscJwt(teamId) {
|
||||
const cached = jwtCache.get(teamId);
|
||||
if (cached && Date.now() / 1000 < cached.exp - 60) return cached.jwt;
|
||||
|
||||
const key = getAscKey(teamId);
|
||||
const privateKey = fs.readFileSync(key.p8Path, 'utf8');
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const exp = now + 1200;
|
||||
const header = b64url(JSON.stringify({ alg: 'ES256', kid: key.key_id, typ: 'JWT' }));
|
||||
const payload = b64url(JSON.stringify({ iss: key.issuer_id, iat: now, exp, aud: 'appstoreconnect-v1' }));
|
||||
const sig = crypto.sign('sha256', Buffer.from(`${header}.${payload}`), { key: privateKey, dsaEncoding: 'ieee-p1363' });
|
||||
|
||||
const jwt = `${header}.${payload}.${b64url(sig)}`;
|
||||
jwtCache.set(teamId, { jwt, exp });
|
||||
return jwt;
|
||||
}
|
||||
|
||||
function invalidateJwtCache(teamId) {
|
||||
if (teamId) jwtCache.delete(teamId);
|
||||
else jwtCache.clear();
|
||||
}
|
||||
|
||||
async function fetchBundleIdsForTeam(teamId) {
|
||||
const jwt = signAscJwt(teamId);
|
||||
const results = [];
|
||||
let url = 'https://api.appstoreconnect.apple.com/v1/bundleIds?limit=200&sort=identifier';
|
||||
|
||||
while (url) {
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`ASC API error (${res.status}): ${body.slice(0, 500)}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
for (const item of json.data || []) {
|
||||
results.push({
|
||||
id: item.id,
|
||||
identifier: item.attributes.identifier,
|
||||
name: item.attributes.name,
|
||||
platform: item.attributes.platform,
|
||||
});
|
||||
}
|
||||
url = json.links?.next || null;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async function fetchAllBundleIds() {
|
||||
const keys = listAscKeys();
|
||||
const groups = [];
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const bundleIds = await fetchBundleIdsForTeam(key.team_id);
|
||||
groups.push({
|
||||
teamId: key.team_id,
|
||||
teamName: key.team_name || key.team_id,
|
||||
bundleIds,
|
||||
error: null,
|
||||
});
|
||||
} catch (err) {
|
||||
groups.push({
|
||||
teamId: key.team_id,
|
||||
teamName: key.team_name || key.team_id,
|
||||
bundleIds: [],
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function testAscKey(teamId) {
|
||||
const jwt = signAscJwt(teamId);
|
||||
const res = await fetch('https://api.appstoreconnect.apple.com/v1/apps?limit=1', {
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`ASC API (${res.status}): ${body.slice(0, 300)}`);
|
||||
}
|
||||
const json = await res.json();
|
||||
return { ok: true, app_count_sample: (json.data || []).length };
|
||||
}
|
||||
|
||||
// --- Delete a profile ---
|
||||
|
||||
function deleteProfile(uuid) {
|
||||
// Remove from installed profiles dir
|
||||
const installedPath = path.join(INSTALLED_PROFILES_DIR, `${uuid}.mobileprovision`);
|
||||
if (fs.existsSync(installedPath)) fs.unlinkSync(installedPath);
|
||||
|
||||
// Remove from DB cache
|
||||
db.prepare('DELETE FROM profiles WHERE profile_uuid = ?').run(uuid);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getProfile,
|
||||
parseMobileprovision,
|
||||
scanInstalledProfiles,
|
||||
scanAllInstalledProfiles,
|
||||
deleteProfile,
|
||||
installProfile,
|
||||
listAscKeys,
|
||||
getAscKey,
|
||||
fetchBundleIdsForTeam,
|
||||
fetchAllBundleIds,
|
||||
testAscKey,
|
||||
invalidateJwtCache,
|
||||
INSTALLED_PROFILES_DIR,
|
||||
ASC_DIR,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,8 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const { db, getSetting, setSetting, DATA_DIR } = require('./db');
|
||||
const { requireLogin, requireBuilderSecret, validatePassword } = require('./auth');
|
||||
const { requireLogin, validatePassword } = require('./auth');
|
||||
const profileManager = require('./profile-manager');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3090;
|
||||
@@ -78,173 +79,123 @@ 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'));
|
||||
app.get('/profiles', requireLogin, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', 'views', 'profiles.html'));
|
||||
});
|
||||
|
||||
// --- Device API ---
|
||||
// --- Profile management 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;
|
||||
app.get('/api/profiles', requireLogin, (req, res) => {
|
||||
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();
|
||||
const profiles = profileManager.scanAllInstalledProfiles();
|
||||
const now = Date.now();
|
||||
|
||||
const result = profiles.map((p) => {
|
||||
const expiresMs = p.expiresAt ? Date.parse(p.expiresAt) : null;
|
||||
let status = 'unknown';
|
||||
if (expiresMs) {
|
||||
const daysLeft = (expiresMs - now) / (1000 * 60 * 60 * 24);
|
||||
if (daysLeft <= 0) status = 'expired';
|
||||
else if (daysLeft < 30) status = 'expiring';
|
||||
else status = 'valid';
|
||||
}
|
||||
|
||||
return {
|
||||
uuid: p.uuid,
|
||||
name: p.name,
|
||||
bundleIdentifier: p.bundleIdentifier,
|
||||
teamId: p.teamId,
|
||||
method: p.method,
|
||||
expiresAt: p.expiresAt,
|
||||
deviceCount: p.deviceCount,
|
||||
status,
|
||||
filePath: p.filePath,
|
||||
};
|
||||
});
|
||||
|
||||
// Sort: ad-hoc first, then by bundle ID
|
||||
result.sort((a, b) => {
|
||||
if (a.method === 'ad-hoc' && b.method !== 'ad-hoc') return -1;
|
||||
if (a.method !== 'ad-hoc' && b.method === 'ad-hoc') return 1;
|
||||
return (a.bundleIdentifier || '').localeCompare(b.bundleIdentifier || '');
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} 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.status(500).json({ error: 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 });
|
||||
});
|
||||
|
||||
// --- Enrollment bridge (called by unraid's /enroll/callback over the LAN) ---
|
||||
|
||||
app.post('/api/devices/from-enrollment', requireBuilderSecret, 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' });
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
let synced = false;
|
||||
app.get('/api/bundle-ids', requireLogin, async (req, res) => {
|
||||
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();
|
||||
const groups = await profileManager.fetchAllBundleIds();
|
||||
res.json(groups);
|
||||
} catch (err) {
|
||||
console.warn('[enrollment] ASC sync failed:', err.message);
|
||||
return res.json({ success: true, synced: false, warning: err.message });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
|
||||
res.json({ success: true, synced });
|
||||
});
|
||||
|
||||
// --- Settings API ---
|
||||
app.delete('/api/profiles/:uuid', requireLogin, (req, res) => {
|
||||
try {
|
||||
profileManager.deleteProfile(req.params.uuid);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
const SETTINGS_KEYS = [
|
||||
'asc_key_id',
|
||||
'asc_issuer_id',
|
||||
'unraid_url',
|
||||
'unraid_token',
|
||||
];
|
||||
app.post('/api/profiles/regenerate', requireLogin, async (req, res) => {
|
||||
const { bundleId, teamId } = req.body || {};
|
||||
if (!bundleId) return res.status(400).json({ error: 'bundleId is required' });
|
||||
if (!teamId) return res.status(400).json({ error: 'teamId is required' });
|
||||
|
||||
try {
|
||||
const info = await profileManager.getProfile(bundleId, { teamId, force: true });
|
||||
res.json({ success: true, profile: info });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/profiles/generate', requireLogin, async (req, res) => {
|
||||
const { bundleId, teamId } = req.body || {};
|
||||
if (!bundleId) return res.status(400).json({ error: 'bundleId is required' });
|
||||
if (!teamId) return res.status(400).json({ error: 'teamId is required' });
|
||||
|
||||
try {
|
||||
const info = await profileManager.getProfile(bundleId, { teamId, force: true });
|
||||
res.json({ success: true, profile: info });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Settings API (storefront only now; ASC keys have their own endpoints) ---
|
||||
|
||||
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;
|
||||
const out = {
|
||||
unraid_url: getSetting('unraid_url') || '',
|
||||
unraid_token: getSetting('unraid_token') ? '••••••••' : '',
|
||||
};
|
||||
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 || '');
|
||||
const { unraid_url, unraid_token } = req.body;
|
||||
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) => {
|
||||
app.post('/api/settings/test-storefront', 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}` });
|
||||
if (!r.ok) return res.status(400).json({ error: `Storefront returned ${r.status}` });
|
||||
const apps = await r.json();
|
||||
res.json({ success: true, app_count: apps.length });
|
||||
} catch (err) {
|
||||
@@ -252,35 +203,106 @@ app.post('/api/settings/test-unraid', requireLogin, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// --- ASC keys API (per-team developer accounts) ---
|
||||
|
||||
app.get('/api/asc-keys', requireLogin, (req, res) => {
|
||||
try {
|
||||
const keys = profileManager.listAscKeys();
|
||||
const out = keys.map((k) => ({
|
||||
team_id: k.team_id,
|
||||
team_name: k.team_name,
|
||||
key_id: k.key_id,
|
||||
issuer_id: k.issuer_id,
|
||||
p8_uploaded: !!k.p8_filename && fs.existsSync(path.join(profileManager.ASC_DIR, k.p8_filename)),
|
||||
created_at: k.created_at,
|
||||
}));
|
||||
res.json(out);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/asc-keys', requireLogin, (req, res) => {
|
||||
const { team_id, team_name, key_id, issuer_id } = req.body || {};
|
||||
if (!team_id || !key_id || !issuer_id) {
|
||||
return res.status(400).json({ error: 'team_id, key_id, and issuer_id are required' });
|
||||
}
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO asc_keys (team_id, team_name, key_id, issuer_id)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(team_id) DO UPDATE SET
|
||||
team_name = excluded.team_name,
|
||||
key_id = excluded.key_id,
|
||||
issuer_id = excluded.issuer_id
|
||||
`).run(team_id, team_name || team_id, key_id, issuer_id);
|
||||
profileManager.invalidateJwtCache(team_id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/asc-keys/:team_id/p8', requireLogin, p8Upload.single('p8'), (req, res) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'No file' });
|
||||
const row = db.prepare('SELECT * FROM asc_keys WHERE team_id = ?').get(req.params.team_id);
|
||||
if (!row) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
return res.status(404).json({ error: 'Team not found — save key details first' });
|
||||
}
|
||||
const filename = `${row.key_id}.p8`;
|
||||
const dest = path.join(profileManager.ASC_DIR, filename);
|
||||
fs.renameSync(req.file.path, dest);
|
||||
fs.chmodSync(dest, 0o600);
|
||||
db.prepare('UPDATE asc_keys SET p8_filename = ? WHERE team_id = ?').run(filename, row.team_id);
|
||||
profileManager.invalidateJwtCache(row.team_id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
if (req.file && fs.existsSync(req.file.path)) {
|
||||
try { fs.unlinkSync(req.file.path); } catch {}
|
||||
}
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/asc-keys/:team_id', requireLogin, (req, res) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT * FROM asc_keys WHERE team_id = ?').get(req.params.team_id);
|
||||
if (!row) return res.status(404).json({ error: 'Team not found' });
|
||||
if (row.p8_filename) {
|
||||
const p8Path = path.join(profileManager.ASC_DIR, row.p8_filename);
|
||||
if (fs.existsSync(p8Path)) fs.unlinkSync(p8Path);
|
||||
const jsonPath = path.join(profileManager.ASC_DIR, `${row.key_id}.json`);
|
||||
if (fs.existsSync(jsonPath)) fs.unlinkSync(jsonPath);
|
||||
}
|
||||
db.prepare('DELETE FROM asc_keys WHERE team_id = ?').run(row.team_id);
|
||||
profileManager.invalidateJwtCache(row.team_id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/asc-keys/:team_id/test', requireLogin, async (req, res) => {
|
||||
try {
|
||||
const result = await profileManager.testAscKey(req.params.team_id);
|
||||
res.json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Build pipeline ---
|
||||
|
||||
require('./build-routes').register(app, { requireLogin });
|
||||
|
||||
// --- 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',
|
||||
version: '2.0.0',
|
||||
service: 'ios-appstore-builder',
|
||||
host: require('os').hostname(),
|
||||
});
|
||||
@@ -289,7 +311,6 @@ app.get('/api/health', (req, res) => {
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`iOS App Store Builder running on port ${PORT}`);
|
||||
console.log(`Data dir: ${DATA_DIR}`);
|
||||
// Start the build worker loop.
|
||||
require('./build-worker').start();
|
||||
console.log('Build worker started');
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<!-- Include pattern note: this file is not served directly; each page hand-inlines the nav for simplicity. -->
|
||||
<header>
|
||||
<div class="header-left"><h1>🔨 Builder</h1></div>
|
||||
<div class="header-left"><h1>Builder</h1></div>
|
||||
<nav>
|
||||
<a href="/">Builds</a>
|
||||
<a href="/devices">Devices</a>
|
||||
<a href="/build">New Build</a>
|
||||
<a href="/profiles">Profiles</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</nav>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-left"><h1>🔨 Builder</h1></div>
|
||||
<div class="header-left"><h1>Builder</h1></div>
|
||||
<nav>
|
||||
<a href="/">Builds</a>
|
||||
<a href="/build" class="active">New Build</a>
|
||||
<a href="/devices">Devices</a>
|
||||
<a href="/profiles">Profiles</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</nav>
|
||||
@@ -22,40 +22,26 @@
|
||||
<h1 class="page-title">New Build</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>From source archive</h2>
|
||||
<h2>Select Xcode Project</h2>
|
||||
<div class="card">
|
||||
<form id="upload-form" enctype="multipart/form-data">
|
||||
<label>Archive (.zip or .tar.gz)</label>
|
||||
<input type="file" name="source" id="source-input" accept=".zip,.tar.gz,.tgz" required>
|
||||
<label>Scheme (optional)</label>
|
||||
<input type="text" name="scheme" placeholder="leave blank to use the first scheme">
|
||||
<div class="btn-row">
|
||||
<button type="submit">Queue Build</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="path-bar" class="path-bar"></div>
|
||||
<div id="file-list" class="file-list">
|
||||
<p style="color:var(--text-muted)">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>From git URL</h2>
|
||||
<div id="project-config" class="section" style="display:none">
|
||||
<h2>Build Configuration</h2>
|
||||
<div class="card">
|
||||
<form id="git-form">
|
||||
<label>Repository URL</label>
|
||||
<input type="text" name="url" placeholder="git@gitea.treytartt.com:user/repo.git or https://…">
|
||||
<div class="field-group">
|
||||
<div>
|
||||
<label>Branch (optional)</label>
|
||||
<input type="text" name="branch" placeholder="main">
|
||||
</div>
|
||||
<div>
|
||||
<label>Scheme (optional)</label>
|
||||
<input type="text" name="scheme" placeholder="first scheme">
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="submit">Queue Build</button>
|
||||
</div>
|
||||
</form>
|
||||
<p id="selected-project" class="mono" style="font-size:13px;color:var(--text-muted);margin-bottom:12px"></p>
|
||||
<label>Scheme</label>
|
||||
<select id="scheme-select" disabled>
|
||||
<option>Select a project first</option>
|
||||
</select>
|
||||
<div class="btn-row">
|
||||
<button id="build-btn" type="button" disabled>Build</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-left"><h1>🔨 Builder</h1></div>
|
||||
<div class="header-left"><h1>Builder</h1></div>
|
||||
<nav>
|
||||
<a href="/" class="active">Builds</a>
|
||||
<a href="/build">New Build</a>
|
||||
<a href="/devices">Devices</a>
|
||||
<a href="/profiles">Profiles</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</nav>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Devices - Builder</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-left"><h1>🔨 Builder</h1></div>
|
||||
<nav>
|
||||
<a href="/">Builds</a>
|
||||
<a href="/devices" class="active">Devices</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h1 class="page-title">Devices</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>Register a device</h2>
|
||||
<div class="card">
|
||||
<form id="add-form">
|
||||
<div class="field-group">
|
||||
<div>
|
||||
<label>UDID</label>
|
||||
<input type="text" name="udid" placeholder="40-char hex or 25-char UUID format" required autocomplete="off">
|
||||
</div>
|
||||
<div>
|
||||
<label>Name</label>
|
||||
<input type="text" name="name" placeholder="Trey's iPhone" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
<button type="submit">Add Device</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Registered devices</h2>
|
||||
<div id="devices-container">
|
||||
<div class="card"><p style="color:var(--text-muted)">Loading…</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
</main>
|
||||
|
||||
<script src="/js/devices.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,10 +8,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-left"><h1>🔨 Builder</h1></div>
|
||||
<div class="header-left"><h1>Builder</h1></div>
|
||||
<nav>
|
||||
<a href="/" class="active">Builds</a>
|
||||
<a href="/devices">Devices</a>
|
||||
<a href="/build">New Build</a>
|
||||
<a href="/profiles">Profiles</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</nav>
|
||||
|
||||
43
builder/views/profiles.html
Normal file
43
builder/views/profiles.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Profiles - Builder</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-left"><h1>Builder</h1></div>
|
||||
<nav>
|
||||
<a href="/">Builds</a>
|
||||
<a href="/build">New Build</a>
|
||||
<a href="/profiles" class="active">Profiles</a>
|
||||
<a href="/settings">Settings</a>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h1 class="page-title">Provisioning Profiles</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>App Store Connect Bundle IDs</h2>
|
||||
<div id="bundle-ids-container">
|
||||
<div class="card"><p style="color:var(--text-muted)">Loading bundle IDs from Apple...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Installed Ad-Hoc Profiles</h2>
|
||||
<div id="profiles-container">
|
||||
<div class="card"><p style="color:var(--text-muted)">Loading...</p></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast"></div>
|
||||
</main>
|
||||
|
||||
<script src="/js/profiles.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,10 +8,11 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="header-left"><h1>🔨 Builder</h1></div>
|
||||
<div class="header-left"><h1>Builder</h1></div>
|
||||
<nav>
|
||||
<a href="/">Builds</a>
|
||||
<a href="/devices">Devices</a>
|
||||
<a href="/build">New Build</a>
|
||||
<a href="/profiles">Profiles</a>
|
||||
<a href="/settings" class="active">Settings</a>
|
||||
<a href="/logout" class="logout">Logout</a>
|
||||
</nav>
|
||||
@@ -21,41 +22,58 @@
|
||||
<h1 class="page-title">Settings</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>App Store Connect API</h2>
|
||||
<h2>Developer Accounts</h2>
|
||||
<p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">
|
||||
One App Store Connect API key per Apple Developer team. Used by fastlane to generate ad-hoc provisioning profiles.
|
||||
The build worker auto-picks the key whose <code>team_id</code> matches the Xcode project's <code>DEVELOPMENT_TEAM</code>.
|
||||
</p>
|
||||
<div class="card">
|
||||
<form id="asc-form">
|
||||
<div id="asc-keys-table"></div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top:20px">Add Developer Account</h3>
|
||||
<div class="card">
|
||||
<form id="add-key-form">
|
||||
<div class="field-group">
|
||||
<div>
|
||||
<label>Team Name (label)</label>
|
||||
<input type="text" name="team_name" placeholder="88Oak Apps" autocomplete="off" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Team ID</label>
|
||||
<input type="text" name="team_id" placeholder="ABCDE12345" autocomplete="off" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<div>
|
||||
<label>Key ID</label>
|
||||
<input type="text" name="asc_key_id" placeholder="ABC123DEF4" autocomplete="off">
|
||||
<input type="text" name="key_id" placeholder="ABC123DEF4" autocomplete="off" required>
|
||||
</div>
|
||||
<div>
|
||||
<label>Issuer ID</label>
|
||||
<input type="text" name="asc_issuer_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off">
|
||||
<input type="text" name="issuer_id" placeholder="00000000-0000-0000-0000-000000000000" autocomplete="off" required>
|
||||
</div>
|
||||
</div>
|
||||
<label>Private Key (.p8 file)</label>
|
||||
<input type="file" id="p8-input" accept=".p8">
|
||||
<p id="p8-status" style="font-size:12px;color:var(--text-muted);margin-bottom:12px"></p>
|
||||
<div class="btn-row">
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" id="test-asc" class="btn-secondary">Test Connection</button>
|
||||
<button type="submit">Save Account</button>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-top:8px">After saving, use the Upload .p8 button in the table above.</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>unraid App Store</h2>
|
||||
<h2>Storefront</h2>
|
||||
<p style="font-size:13px;color:var(--text-muted);margin-bottom:12px">Where built IPAs get uploaded for OTA distribution.</p>
|
||||
<div class="card">
|
||||
<form id="unraid-form">
|
||||
<form id="storefront-form">
|
||||
<label>Base URL</label>
|
||||
<input type="url" name="unraid_url" placeholder="https://appstore.treytartt.com">
|
||||
<label>API Token</label>
|
||||
<input type="password" name="unraid_token" placeholder="API token from unraid .env">
|
||||
<input type="password" name="unraid_token" placeholder="API token from storefront .env">
|
||||
<div class="btn-row">
|
||||
<button type="submit">Save</button>
|
||||
<button type="button" id="test-unraid" class="btn-secondary">Test Connection</button>
|
||||
<button type="button" id="test-storefront" class="btn-secondary">Test Connection</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
2686
package-lock.json
generated
Normal file
2686
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,15 @@
|
||||
"dev": "node --watch src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"bplist-parser": "^0.3.2",
|
||||
"express": "^4.21.0",
|
||||
"express-session": "^1.18.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"plist": "^3.1.0",
|
||||
"sharp": "^0.33.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"uuid": "^10.0.0",
|
||||
"sharp": "^0.33.5"
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,23 @@
|
||||
const unzipper = require('unzipper');
|
||||
const plist = require('plist');
|
||||
const bplist = require('bplist-parser');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sharp = require('sharp');
|
||||
|
||||
async function parsePlist(buffer) {
|
||||
// Try XML plist first
|
||||
try {
|
||||
return plist.parse(buffer.toString('utf-8'));
|
||||
} catch {}
|
||||
|
||||
// Try binary plist
|
||||
const parsed = bplist.parseBuffer(buffer);
|
||||
if (parsed && parsed.length > 0) return parsed[0];
|
||||
|
||||
throw new Error('Could not parse plist (neither XML nor binary)');
|
||||
}
|
||||
|
||||
async function parseIPA(ipaPath, outputDir) {
|
||||
const directory = await unzipper.Open.file(ipaPath);
|
||||
|
||||
@@ -17,13 +31,7 @@ async function parseIPA(ipaPath, outputDir) {
|
||||
}
|
||||
|
||||
const plistBuffer = await infoPlistEntry.buffer();
|
||||
let info;
|
||||
try {
|
||||
info = plist.parse(plistBuffer.toString('utf-8'));
|
||||
} catch {
|
||||
// Binary plist — try to parse differently
|
||||
throw new Error('Binary plist detected. Please ensure IPA contains XML plist.');
|
||||
}
|
||||
const info = await parsePlist(plistBuffer);
|
||||
|
||||
const metadata = {
|
||||
bundleId: info.CFBundleIdentifier,
|
||||
|
||||
Reference in New Issue
Block a user