322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
import { Component, inject, OnInit } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { BehaviorSubject, firstValueFrom } from 'rxjs';
|
|
|
|
import {
|
|
MobileBuildArtifact,
|
|
MobileBuildDefaults,
|
|
MobileBuildRequest,
|
|
ThemeStatus,
|
|
} from '../interfaces/mobile-build';
|
|
import { MobileBuildService } from '../services/mobile-build.service';
|
|
import { getAppConfig } from '../services/app-config.service';
|
|
|
|
@Component({
|
|
selector: 'app-admin-mobile-builds-tab',
|
|
standalone: true,
|
|
imports: [CommonModule, FormsModule],
|
|
templateUrl: './admin-mobile-builds-tab.component.html',
|
|
styleUrl: './admin-mobile-builds-tab.component.scss',
|
|
})
|
|
export class AdminMobileBuildsTabComponent implements OnInit {
|
|
private mobileBuildService = inject(MobileBuildService);
|
|
|
|
readonly mobileBuilds$ = new BehaviorSubject<MobileBuildRequest[]>([]);
|
|
readonly themeJsonStatus$ = new BehaviorSubject<ThemeStatus | null>(null);
|
|
readonly themeJsonLoading$ = new BehaviorSubject<boolean>(false);
|
|
|
|
mobileBuildDefaults: MobileBuildDefaults | null = null;
|
|
selectedMobileBuildId: number | null = null;
|
|
selectedMobileBuild: MobileBuildRequest | null = null;
|
|
mobileBuildStatusMessage = '';
|
|
mobileBuildErrorMessage = '';
|
|
mobileBuildPreflightIssues: string[] = [];
|
|
mobileBuildBusy = false;
|
|
|
|
buildAndroid = true;
|
|
buildIos = true;
|
|
androidApplicationId = '';
|
|
iosBundleId = '';
|
|
buildVersionName = '1.0.0';
|
|
buildNumber = '1';
|
|
buildReleaseProfile = 'release';
|
|
|
|
androidKeystoreFile: File | null = null;
|
|
androidDescriptorFile: File | null = null;
|
|
iosCertificateFile: File | null = null;
|
|
iosProfileFile: File | null = null;
|
|
|
|
ngOnInit(): void {
|
|
this.loadThemeJsonStatus();
|
|
this.reloadMobileBuildDefaults();
|
|
this.reloadMobileBuilds();
|
|
}
|
|
|
|
async loadThemeJsonStatus(): Promise<void> {
|
|
try {
|
|
this.themeJsonLoading$.next(true);
|
|
this.themeJsonStatus$.next(await firstValueFrom(
|
|
this.mobileBuildService.getThemeStatus(),
|
|
));
|
|
} catch {
|
|
this.themeJsonStatus$.next(null);
|
|
} finally {
|
|
this.themeJsonLoading$.next(false);
|
|
}
|
|
}
|
|
|
|
async regenerateThemeJson(): Promise<void> {
|
|
try {
|
|
this.themeJsonLoading$.next(true);
|
|
await firstValueFrom(this.mobileBuildService.regenerateTheme());
|
|
await this.loadThemeJsonStatus();
|
|
} catch (err) {
|
|
this.mobileBuildErrorMessage = this.getErrorMessage(
|
|
err,
|
|
'Failed to regenerate theme.json.',
|
|
);
|
|
} finally {
|
|
this.themeJsonLoading$.next(false);
|
|
}
|
|
}
|
|
|
|
async reloadMobileBuildDefaults(): Promise<void> {
|
|
try {
|
|
this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults());
|
|
} catch {
|
|
this.mobileBuildDefaults = null;
|
|
}
|
|
}
|
|
|
|
async reloadMobileBuilds(): Promise<void> {
|
|
const data = await firstValueFrom(this.mobileBuildService.listRequests());
|
|
this.mobileBuilds$.next(data);
|
|
|
|
if (this.selectedMobileBuildId !== null) {
|
|
const selected = data.find((item) => item.id === this.selectedMobileBuildId) ?? null;
|
|
this.selectedMobileBuild = selected;
|
|
if (!selected) {
|
|
this.selectedMobileBuildId = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
onMobileBuildSelected(buildId: number): void {
|
|
this.selectedMobileBuildId = buildId;
|
|
const selected = this.mobileBuilds$.value.find((item) => item.id === buildId) ?? null;
|
|
this.selectedMobileBuild = selected;
|
|
this.mobileBuildPreflightIssues = selected?.preflight_report
|
|
? selected.preflight_report.split('\n').filter((line) => line.trim().length > 0)
|
|
: [];
|
|
}
|
|
|
|
onFileChosen(event: Event, target: 'android-keystore' | 'android-descriptor' | 'ios-certificate' | 'ios-profile'): void {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files && input.files.length > 0 ? input.files[0] : null;
|
|
|
|
if (target === 'android-keystore') this.androidKeystoreFile = file;
|
|
if (target === 'android-descriptor') this.androidDescriptorFile = file;
|
|
if (target === 'ios-certificate') this.iosCertificateFile = file;
|
|
if (target === 'ios-profile') this.iosProfileFile = file;
|
|
}
|
|
|
|
private getErrorMessage(err: unknown, fallback: string): string {
|
|
if (typeof err === 'object' && err !== null) {
|
|
const maybeErr = err as { error?: { detail?: string | string[]; message?: string }; message?: string };
|
|
const detail = maybeErr.error?.detail;
|
|
if (typeof detail === 'string') return detail;
|
|
if (Array.isArray(detail)) return detail.join(' | ');
|
|
if (typeof maybeErr.error?.message === 'string') return maybeErr.error.message;
|
|
if (typeof maybeErr.message === 'string') return maybeErr.message;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
private resetMobileBuildMessages(): void {
|
|
this.mobileBuildStatusMessage = '';
|
|
this.mobileBuildErrorMessage = '';
|
|
}
|
|
|
|
private validateBuildInputs(): string | null {
|
|
if (!this.buildAndroid && !this.buildIos) {
|
|
return 'Select at least one platform.';
|
|
}
|
|
|
|
if (this.buildAndroid && !this.androidApplicationId.trim()) {
|
|
return 'Android application ID is required.';
|
|
}
|
|
|
|
if (this.buildIos && !this.iosBundleId.trim()) {
|
|
return 'iOS bundle ID is required.';
|
|
}
|
|
|
|
if (!this.buildVersionName.trim() || !this.buildNumber.trim()) {
|
|
return 'Version name and build number are required.';
|
|
}
|
|
|
|
if (this.buildAndroid && (!this.androidKeystoreFile || !this.androidDescriptorFile)) {
|
|
return 'Android requires both keystore and descriptor files.';
|
|
}
|
|
|
|
if (this.buildIos && (!this.iosCertificateFile || !this.iosProfileFile)) {
|
|
return 'iOS requires both certificate and provisioning profile files.';
|
|
}
|
|
|
|
// Validate file extensions before hitting the backend
|
|
const extChecks: Array<{ file: File | null; label: string; exts: string[] }> = [];
|
|
if (this.buildAndroid) {
|
|
extChecks.push({
|
|
file: this.androidKeystoreFile,
|
|
label: 'Android keystore',
|
|
exts: ['.jks', '.keystore', '.p12'],
|
|
});
|
|
extChecks.push({
|
|
file: this.androidDescriptorFile,
|
|
label: 'Android descriptor',
|
|
exts: ['.json'],
|
|
});
|
|
}
|
|
if (this.buildIos) {
|
|
extChecks.push({
|
|
file: this.iosCertificateFile,
|
|
label: 'iOS certificate',
|
|
exts: ['.p12'],
|
|
});
|
|
extChecks.push({
|
|
file: this.iosProfileFile,
|
|
label: 'iOS provisioning profile',
|
|
exts: ['.mobileprovision'],
|
|
});
|
|
}
|
|
|
|
for (const { file, label, exts } of extChecks) {
|
|
if (!file) continue;
|
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
|
if (!exts.includes(ext)) {
|
|
return `${label} must have one of: ${exts.join(', ')}`;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async createAndUploadMobileBuildRequest(): Promise<void> {
|
|
this.resetMobileBuildMessages();
|
|
this.mobileBuildPreflightIssues = [];
|
|
const inputError = this.validateBuildInputs();
|
|
if (inputError) {
|
|
this.mobileBuildErrorMessage = inputError;
|
|
return;
|
|
}
|
|
|
|
this.mobileBuildBusy = true;
|
|
let createdRequestId: number | null = null;
|
|
try {
|
|
const request = await firstValueFrom(this.mobileBuildService.createRequest({
|
|
platform_android: this.buildAndroid,
|
|
platform_ios: this.buildIos,
|
|
android_application_id: this.androidApplicationId.trim() || undefined,
|
|
ios_bundle_id: this.iosBundleId.trim() || undefined,
|
|
version_name: this.buildVersionName.trim(),
|
|
build_number: this.buildNumber.trim(),
|
|
release_profile: this.buildReleaseProfile.trim() || 'release',
|
|
}));
|
|
createdRequestId = request.id;
|
|
|
|
if (this.buildAndroid && this.androidKeystoreFile && this.androidDescriptorFile) {
|
|
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'keystore', this.androidKeystoreFile));
|
|
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'android', 'descriptor', this.androidDescriptorFile));
|
|
}
|
|
|
|
if (this.buildIos && this.iosCertificateFile && this.iosProfileFile) {
|
|
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'certificate', this.iosCertificateFile));
|
|
await firstValueFrom(this.mobileBuildService.uploadFile(request.id, 'ios', 'profile', this.iosProfileFile));
|
|
}
|
|
|
|
this.mobileBuildStatusMessage = `Draft build request #${request.id} created and files uploaded.`;
|
|
await this.reloadMobileBuilds();
|
|
this.onMobileBuildSelected(request.id);
|
|
} catch (err) {
|
|
// Clean up orphaned request if files couldn't all be uploaded
|
|
if (createdRequestId !== null) {
|
|
await firstValueFrom(
|
|
this.mobileBuildService.deleteRequest(createdRequestId),
|
|
).catch(() => {}); // best-effort cleanup
|
|
await this.reloadMobileBuilds().catch(() => {});
|
|
}
|
|
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to create build request.');
|
|
} finally {
|
|
this.mobileBuildBusy = false;
|
|
}
|
|
}
|
|
|
|
async runPreflightForSelectedBuild(): Promise<void> {
|
|
if (!this.selectedMobileBuildId) return;
|
|
|
|
this.resetMobileBuildMessages();
|
|
this.mobileBuildBusy = true;
|
|
try {
|
|
const result = await firstValueFrom(this.mobileBuildService.preflight(this.selectedMobileBuildId));
|
|
this.mobileBuildPreflightIssues = result.issues;
|
|
this.mobileBuildStatusMessage = result.passed
|
|
? 'Preflight passed. Build can be triggered.'
|
|
: 'Preflight failed. Resolve the listed issues.';
|
|
await this.reloadMobileBuilds();
|
|
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
|
} catch (err) {
|
|
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Preflight failed to run.');
|
|
} finally {
|
|
this.mobileBuildBusy = false;
|
|
}
|
|
}
|
|
|
|
async triggerSelectedBuild(): Promise<void> {
|
|
if (!this.selectedMobileBuildId) return;
|
|
|
|
this.resetMobileBuildMessages();
|
|
this.mobileBuildBusy = true;
|
|
try {
|
|
const request = await firstValueFrom(this.mobileBuildService.trigger(this.selectedMobileBuildId));
|
|
this.mobileBuildStatusMessage = `Build #${request.id} queued.`;
|
|
await this.reloadMobileBuilds();
|
|
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
|
} catch (err) {
|
|
this.mobileBuildErrorMessage = this.getErrorMessage(err, 'Failed to trigger build.');
|
|
} finally {
|
|
this.mobileBuildBusy = false;
|
|
}
|
|
}
|
|
|
|
async refreshSelectedBuild(): Promise<void> {
|
|
if (!this.selectedMobileBuildId) return;
|
|
await this.reloadMobileBuilds();
|
|
this.onMobileBuildSelected(this.selectedMobileBuildId);
|
|
}
|
|
|
|
getArtifactDownloadHref(path: string): string {
|
|
return `${getAppConfig().apiBaseUrl}${path}`;
|
|
}
|
|
|
|
formatFileSize(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
const size = bytes / Math.pow(1024, i);
|
|
return `${size.toFixed(size < 10 ? 1 : 0)} ${units[i]}`;
|
|
}
|
|
|
|
downloadArtifact(artifact: MobileBuildArtifact): void {
|
|
this.mobileBuildService
|
|
.getDownloadUrl(artifact.id)
|
|
.subscribe({
|
|
next: (res) => {
|
|
window.open(res.download_url, '_blank');
|
|
},
|
|
error: (err) => {
|
|
console.error('Failed to get download URL:', err);
|
|
},
|
|
});
|
|
}
|
|
}
|