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([]); readonly themeJsonStatus$ = new BehaviorSubject(null); readonly themeJsonLoading$ = new BehaviorSubject(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 { 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 { 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 { try { this.mobileBuildDefaults = await firstValueFrom(this.mobileBuildService.getDefaults()); } catch { this.mobileBuildDefaults = null; } } async reloadMobileBuilds(): Promise { 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 { 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 { 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 { 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 { 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); }, }); } }