oauth = new Github_Oauth(); $this->api = new Github_Api_Client( $this->oauth ); $this->store = new Mobile_App_Store(); } /** * Generate + commit the project, push the signing secrets, and * kick off a build. Persists the (possibly updated) config either * way. * * @param Mobile_App_Config $config * * @return true|\WP_Error */ public function build( Mobile_App_Config $config, $platforms = 'both' ) { $platforms = in_array( $platforms, array( 'both', 'android', 'ios' ), true ) ? $platforms : 'both'; if ( ! $this->oauth->is_connected() ) { return $this->fail( $config, new \WP_Error( 'esipher_app_not_connected', __( 'Connect your GitHub account first, under Esipher → Connected Projects.', 'esipher' ) ) ); } $invalid = $this->validate( $config ); if ( is_wp_error( $invalid ) ) { return $this->fail( $config, $invalid ); } $repo_info = $this->api->get_repo( $config->owner, $config->repo ); if ( is_wp_error( $repo_info ) ) { $code = (int) $repo_info->get_error_code(); if ( 401 === $code ) { $message = __( 'GitHub rejected the saved connection ("Bad credentials"). Go to Esipher → Connected Projects, click Disconnect, then Save & Connect to refresh it, and try again.', 'esipher' ); } elseif ( 404 === $code ) { $message = sprintf( /* translators: %s: owner/repo. */ __( 'The app repo %s could not be found. Create it on GitHub (with a README) and set its owner/name on the Android tab.', 'esipher' ), $config->full_name() ); } else { $message = sprintf( /* translators: %s: GitHub error message. */ __( 'Could not access the app repo: %s', 'esipher' ), $repo_info->get_error_message() ); } return $this->fail( $config, new \WP_Error( 'esipher_app_repo', $message ) ); } if ( '' === $config->branch ) { $config->branch = ! empty( $repo_info['default_branch'] ) ? $repo_info['default_branch'] : 'main'; } // The signing key is created exactly once and then reused for // the life of the app — Google Play binds the listing to it // permanently. if ( ! $this->store->has_keystore() ) { $password = wp_generate_password( 28, false ); $keystore = Android_Keystore::generate( $config->app_name, $password ); if ( is_wp_error( $keystore ) ) { return $this->fail( $config, $keystore ); } $this->store->save_keystore( $keystore['p12_base64'], $password ); $config->keystore_fingerprint = $keystore['fingerprint_sha256']; } // Whether this build touches iOS at all, and whether iOS signing // is fully in place. Provisioning (which can fail on Apple's cert // limit) is only attempted when the build actually includes iOS — // so an Android-only build is never blocked by an Apple problem. $build_ios = ( 'android' !== $platforms ) && $config->ios_configured() && $this->store->has_asc_key(); $provision_err = ''; if ( $build_ios && ! $this->store->has_ios_signing() ) { // Provision the iOS distribution certificate + App Store // profile once via the App Store Connect API and reuse them // forever (Apple caps distribution certs per account). $asc = new App_Store_Connect_Api( $config->asc_key_id, $config->asc_issuer_id, $this->store->asc_key() ); $signing = Ios_Signing::provision( $asc, $config->app_id, $config->app_name ); if ( is_wp_error( $signing ) ) { // If the caller explicitly asked for iOS, this is fatal. // For a "build both" request, let Android proceed and // report the iOS problem instead. if ( 'ios' === $platforms ) { return $this->fail( $config, $signing ); } $build_ios = false; $provision_err = $signing->get_error_message(); } else { $this->store->save_ios_signing( $signing['p12_base64'], $signing['p12_password'], $signing['mobileprovision_base64'] ); $config->ios_profile_name = $signing['profile_name']; } } // The iOS job / secrets / repo flag only make sense once signing // is genuinely ready, regardless of which tab triggered this. $ios_ready = $config->ios_configured() && $this->store->has_asc_key() && $this->store->has_ios_signing(); $files = ( new Mobile_App_Project() )->files( $config, $ios_ready ); if ( is_wp_error( $files ) ) { return $this->fail( $config, $files ); } $committed = $this->api->commit_files( $config->owner, $config->repo, $config->branch, $files, 'Esipher: generate/update the app project' ); if ( is_wp_error( $committed ) ) { return $this->fail( $config, $committed ); } $secrets = array( self::SECRET_KEYSTORE => $this->store->keystore_base64(), self::SECRET_PASSWORD => $this->store->keystore_password(), self::SECRET_ALIAS => Android_Keystore::ALIAS, ); if ( $ios_ready ) { $secrets[ self::SECRET_APPLE_TEAM_ID ] = $config->apple_team_id; $secrets[ self::SECRET_IOS_P12 ] = $this->store->ios_p12_base64(); $secrets[ self::SECRET_IOS_P12_PASS ] = $this->store->ios_p12_password(); $secrets[ self::SECRET_IOS_PROFILE ] = $this->store->ios_mobileprovision_base64(); $secrets[ self::SECRET_IOS_PROFILE_NAME ] = $config->ios_profile_name; } foreach ( $secrets as $name => $value ) { $pushed = $this->api->put_actions_secret( $config->owner, $config->repo, $name, $value ); if ( is_wp_error( $pushed ) ) { return $this->fail( $config, new \WP_Error( 'esipher_app_secret', sprintf( /* translators: 1: secret name, 2: error message. */ __( 'Could not set the "%1$s" repository secret: %2$s', 'esipher' ), $name, $pushed->get_error_message() ) ) ); } } // The build-ios job also checks this repo variable, so it can // never run before signing is provisioned (a job-level if: can't // read secrets). $flagged = $this->api->put_actions_variable( $config->owner, $config->repo, 'ESIPHER_IOS_ENABLED', $ios_ready ? 'true' : 'false' ); if ( is_wp_error( $flagged ) ) { return $this->fail( $config, new \WP_Error( 'esipher_app_variable', sprintf( /* translators: %s: error message. */ __( 'Could not set the iOS build flag on the repo: %s', 'esipher' ), $flagged->get_error_message() ) ) ); } // The workflow only runs when explicitly dispatched (it has no // `on: push`), so this must succeed — the secrets and variable // above are now all in place. A brand-new workflow file can 404 // for a few seconds until GitHub registers it, so retry briefly. // If iOS isn't set up yet, a "both" request is really Android // only — don't ask GitHub to run a job it would skip. $dispatch_platforms = ( 'both' === $platforms && ! $ios_ready ) ? 'android' : $platforms; $dispatched = null; for ( $attempt = 1; $attempt <= 3; $attempt++ ) { $dispatched = $this->api->dispatch_workflow( $config->owner, $config->repo, $config->branch, self::WORKFLOW_FILE, array( 'platforms' => $dispatch_platforms ) ); if ( ! is_wp_error( $dispatched ) ) { break; } if ( $attempt < 3 ) { sleep( 3 ); } } $now = current_time( 'mysql' ); $builds_android = ( 'ios' !== $dispatch_platforms ); $builds_ios = ( 'android' !== $dispatch_platforms ) && $ios_ready; $stalled = is_wp_error( $dispatched ); if ( $builds_android ) { $config->status = 'building'; $config->last_error = $stalled ? __( 'Project and secrets are set, but GitHub has not registered the workflow yet. Wait ~30 seconds and click Rebuild.', 'esipher' ) : ''; $config->last_build_dispatched_at = $now; } if ( $builds_ios ) { $config->ios_status = 'building'; $config->ios_last_error = $stalled ? __( 'Project and secrets are set, but GitHub has not registered the workflow yet. Wait ~30 seconds and click Rebuild.', 'esipher' ) : ''; $config->ios_last_build_dispatched_at = $now; } elseif ( '' !== $provision_err ) { $config->ios_status = 'error'; $config->ios_last_error = sprintf( /* translators: %s: iOS provisioning error. */ __( 'iOS signing could not be set up: %s', 'esipher' ), $provision_err ); } elseif ( ! $ios_ready ) { $config->ios_status = ''; $config->ios_last_error = ''; } $this->store->save( $config ); return true; } /** * Download the newest successful build's artifact zip. * * @param string $platform 'android' (app-debug.apk + app-release.aab) * or 'ios' (App.ipa). * * @return string|\WP_Error Path to a local .zip temp file. */ public function download_latest_build( $platform = 'android' ) { $config = $this->store->get(); if ( ! $config || '' === $config->repo ) { return new \WP_Error( 'esipher_app_none', __( 'No app has been generated yet.', 'esipher' ) ); } if ( ! $this->oauth->is_connected() ) { return new \WP_Error( 'esipher_app_not_connected', __( 'Connect your GitHub account first.', 'esipher' ) ); } $artifact_name = 'ios' === $platform ? self::ARTIFACT_NAME_IOS : self::ARTIFACT_NAME; $run = $this->api->get_latest_run( $config->owner, $config->repo, $config->branch, self::WORKFLOW_FILE ); if ( is_wp_error( $run ) ) { return $run; } if ( 'completed' !== ( $run['status'] ?? '' ) ) { return new \WP_Error( 'esipher_app_build_running', __( 'The latest build is still running — check back in a minute or two.', 'esipher' ) ); } // The iOS job can fail while the Android job succeeds (or vice // versa), so a run-level "failure" isn't necessarily fatal for // the platform being asked about — fall through to the artifact // lookup, which reports precisely when that platform's file // isn't there. if ( 'success' !== ( $run['conclusion'] ?? '' ) && 'ios' !== $platform && 'android' !== $platform ) { return new \WP_Error( 'esipher_app_build_failed', __( 'The latest build did not finish successfully — open the repo\'s Actions tab to see why.', 'esipher' ) ); } $artifact = $this->api->find_build_artifact( $config->owner, $config->repo, (int) $run['id'], $artifact_name ); if ( is_wp_error( $artifact ) ) { if ( 'ios' === $platform ) { return new \WP_Error( 'esipher_app_ios_artifact', __( 'No iOS build in the latest run. If you just enabled iOS, wait for the next build to finish; otherwise open the repo\'s Actions tab and check the "build-ios" job.', 'esipher' ) ); } return $artifact; } return $this->api->download_artifact( $config->owner, $config->repo, (int) $artifact['id'] ); } /** * Refresh $config->status from the latest run, for display. * * @param Mobile_App_Config $config * * @return Mobile_App_Config */ public function refresh_status( Mobile_App_Config $config ) { if ( '' === $config->repo || ! $this->oauth->is_connected() ) { return $config; } $run = $this->api->get_latest_run( $config->owner, $config->repo, $config->branch, self::WORKFLOW_FILE ); if ( is_wp_error( $run ) ) { return $config; } $jobs = $this->api->get_run_jobs( $config->owner, $config->repo, (int) $run['id'] ); $jobs = is_wp_error( $jobs ) ? array() : $jobs; $map = function ( $job ) { if ( ! $job ) { return null; // Job didn't run in this build. } if ( 'completed' !== $job['status'] ) { return 'building'; } return 'success' === $job['conclusion'] ? 'built' : 'error'; }; $android = $map( $jobs['build'] ?? null ); $ios = $map( $jobs['build-ios'] ?? null ); $fail_msg = __( 'The last build failed — open the repo\'s Actions tab for the log.', 'esipher' ); if ( null !== $android ) { $config->status = $android; $config->last_error = 'error' === $android ? $fail_msg : ''; } if ( ! $config->ios_configured() ) { $config->ios_status = ''; $config->ios_last_error = ''; } elseif ( null !== $ios ) { $config->ios_status = $ios; $config->ios_last_error = 'error' === $ios ? $fail_msg : ''; } $this->store->save( $config ); return $config; } /** * @param Mobile_App_Config $config * * @return true|\WP_Error */ private function validate( Mobile_App_Config $config ) { if ( '' === trim( $config->app_name ) ) { return new \WP_Error( 'esipher_app_name', __( 'Enter an app name.', 'esipher' ) ); } if ( ! preg_match( '/^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/', $config->app_id ) ) { return new \WP_Error( 'esipher_app_id', __( 'The app ID must be reverse-domain style, lowercase, e.g. com.yourname.appname.', 'esipher' ) ); } if ( ! preg_match( '#^https?://[^\s/]+#i', $config->site_url ) ) { return new \WP_Error( 'esipher_app_url', __( 'Enter the full site URL the app should open, including https://.', 'esipher' ) ); } if ( '' === $config->owner || '' === $config->repo ) { return new \WP_Error( 'esipher_app_repo_blank', __( 'Enter the GitHub repo (owner/name) to build the app in.', 'esipher' ) ); } return true; } /** * Record an error on the config and return it. * * @param Mobile_App_Config $config * @param \WP_Error $error * * @return \WP_Error */ private function fail( Mobile_App_Config $config, \WP_Error $error ) { $config->status = 'error'; $config->last_error = $error->get_error_message(); $this->store->save( $config ); return $error; } }