Mobile App" admin submenu: settings for the Android * WebView-wrapper app, plus the "Generate & build" and "Download latest * build" actions. Reuses the GitHub connection from Connected Projects. * * @package Esipher */ namespace Esipher\Admin; use Esipher\Github_Oauth; use Esipher\Mobile_App_Config; use Esipher\Mobile_App_Service; use Esipher\Mobile_App_Store; if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Registers the submenu page and its admin-post handlers. */ class Mobile_App_Admin_Page { const MENU_SLUG = 'esipher-mobile-app'; const ACTION_SAVE_BUILD = 'esipher_app_save_build'; const ACTION_SAVE_IOS = 'esipher_app_save_ios'; const ACTION_DOWNLOAD = 'esipher_app_download'; const ACTION_REBUILD = 'esipher_app_rebuild'; /** * This page's hook suffix, from add_submenu_page(), so * enqueue_assets() can load the media library only here. * * @var string */ private $hook = ''; public function register() { add_action( 'admin_menu', array( $this, 'add_menu' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'admin_post_' . self::ACTION_SAVE_BUILD, array( $this, 'handle_save_build' ) ); add_action( 'admin_post_' . self::ACTION_SAVE_IOS, array( $this, 'handle_save_ios' ) ); add_action( 'admin_post_' . self::ACTION_DOWNLOAD, array( $this, 'handle_download' ) ); add_action( 'admin_post_' . self::ACTION_REBUILD, array( $this, 'handle_rebuild' ) ); } public function add_menu() { $this->hook = (string) add_submenu_page( Admin_Page::MENU_SLUG, __( 'Mobile App', 'esipher' ), __( 'Mobile App', 'esipher' ), Admin_Page::CAPABILITY, self::MENU_SLUG, array( $this, 'render_page' ) ); } /** * Load the media library and the icon-picker script — only on this * page. The script is attached to 'media-editor' so it runs after * wp.media exists, not mid-body before the footer scripts load. * * @param string $hook_suffix Current admin page's hook suffix. */ public function enqueue_assets( $hook_suffix ) { if ( '' === $this->hook || $hook_suffix !== $this->hook ) { return; } wp_enqueue_media(); wp_add_inline_script( 'media-editor', $this->icon_picker_js() ); } private function page_url( array $args = array() ) { return add_query_arg( $args, admin_url( 'admin.php?page=' . self::MENU_SLUG ) ); } public function render_page() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'esipher' ) ); } $oauth = new Github_Oauth(); $store = new Mobile_App_Store(); $config = $store->get() ?: $this->defaults(); if ( $store->get() && $oauth->is_connected() ) { $config = ( new Mobile_App_Service() )->refresh_status( $config ); } Admin_Layout::open( self::MENU_SLUG, __( 'Esipher - Mobile App', 'esipher' ) ); echo $this->render_notice(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_notice() escapes. if ( ! $oauth->is_connected() ) { echo '

' . sprintf( /* translators: 1: opening , 2: closing . */ esc_html__( 'Connect your GitHub account first under %1$sConnected Projects%2$s, the app build uses the same connection.', 'esipher' ), '', '' ) . '

'; Admin_Layout::close(); return; } $tab = isset( $_GET['tab'] ) && 'ios' === $_GET['tab'] ? 'ios' : 'android'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch. Admin_Layout::side_open( array( 'android' => array( 'label' => __( 'Android', 'esipher' ), 'url' => $this->page_url(), ), 'ios' => array( 'label' => __( 'iOS', 'esipher' ), 'url' => $this->page_url( array( 'tab' => 'ios' ) ), ), ), $tab, __( 'Mobile app platform', 'esipher' ) ); if ( 'ios' === $tab ) { $this->render_ios_tab( $config, $store ); } else { $this->render_form( $config, $store ); $this->render_build_panel( $config, $store ); } Admin_Layout::side_close(); Admin_Layout::close(); } private function render_ios_tab( Mobile_App_Config $config, Mobile_App_Store $store ) { Admin_Layout::card_open( __( 'App Store Connect', 'esipher' ), __( 'Build a signed iOS app (.ipa) for the App Store / TestFlight. Needs a paid Apple Developer account ($99/year) and an App Store Connect API key.', 'esipher' ) ); if ( ! $store->get() || '' === $config->repo ) { echo '

' . sprintf( /* translators: 1: opening , 2: closing . */ esc_html__( 'Set up the app on the %1$sAndroid tab%2$s first, name, App ID, icon and the GitHub repo are shared. iOS builds in that same repo. Then fill in the Apple details here.', 'esipher' ), '', '' ) . '

'; Admin_Layout::card_close(); return; } echo '

' . sprintf( /* translators: %s: owner/repo. */ esc_html__( 'iOS builds in the same GitHub repo as Android: %s. There is no separate repo to set here.', 'esipher' ), '' . esc_html( $config->full_name() ) . '' ) . '

'; echo '

' . sprintf( /* translators: 1: opening , 2: closing . */ esc_html__( 'Create a key at %1$sApp Store Connect → Users and Access → Integrations → App Store Connect API%2$s (role: App Manager or Developer). Download the .p8 file once and copy its Key ID and Issuer ID.', 'esipher' ), '', '' ) . '

'; echo '
'; wp_nonce_field( self::ACTION_SAVE_IOS ); echo ''; echo ''; $this->text_row( 'esipher_apple_team_id', __( 'Apple Team ID', 'esipher' ), $config->apple_team_id, __( '10 characters — Apple Developer → Membership details.', 'esipher' ), 'A1B2C3D4E5' ); $this->text_row( 'esipher_asc_key_id', __( 'API Key ID', 'esipher' ), $config->asc_key_id, __( 'Shown next to the key you created.', 'esipher' ), '2X9ABC3DEF' ); $this->text_row( 'esipher_asc_issuer_id', __( 'Issuer ID', 'esipher' ), $config->asc_issuer_id, __( 'At the top of the App Store Connect API page — a UUID.', 'esipher' ), '69a6de70-...' ); echo ''; echo '
'; echo ''; echo '

' . ( $store->has_asc_key() ? esc_html__( 'A key is stored. Paste a new one here only to replace it.', 'esipher' ) : esc_html__( 'Open the downloaded AuthKey_XXXX.p8 in a text editor and paste its full contents.', 'esipher' ) ) . '

'; submit_button( __( 'Save iOS settings & build', 'esipher' ) ); echo '
'; Admin_Layout::card_close(); $this->render_ios_build_panel( $config, $store ); } private function render_ios_build_panel( Mobile_App_Config $config, Mobile_App_Store $store ) { if ( ! $config->ios_configured() || ! $store->has_asc_key() ) { return; } Admin_Layout::card_open( __( 'iOS build', 'esipher' ) ); echo ''; echo ''; echo ''; if ( '' !== $config->ios_last_build_dispatched_at ) { echo ''; } echo '
' . esc_html__( 'Status', 'esipher' ) . '' . $this->status_pill( '' !== $config->ios_status ? $config->ios_status : 'new' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- status_pill() escapes. if ( '' !== $config->ios_last_error ) { echo '

' . esc_html( $config->ios_last_error ) . '

'; } echo '
' . esc_html__( 'Runner', 'esipher' ) . '' . esc_html__( 'macOS (GitHub-hosted). Free for a public repo; billed on a private one.', 'esipher' ) . '
' . esc_html__( 'Last build started', 'esipher' ) . '' . esc_html( $config->ios_last_build_dispatched_at ) . '
'; echo '

'; echo '' . esc_html__( 'Download latest .ipa', 'esipher' ) . ' '; echo '' . esc_html__( 'Rebuild iOS', 'esipher' ) . ' '; echo '' . esc_html__( 'Rebuild Android + iOS', 'esipher' ) . ''; echo '

'; echo '

' . esc_html__( 'The .ipa is signed with the distribution certificate Esipher provisioned. Upload it to App Store Connect (Transporter app or `xcrun altool`), then submit for TestFlight or review. The first upload also needs an app record created in App Store Connect.', 'esipher' ) . '

'; Admin_Layout::card_close(); } private function defaults() { $config = new Mobile_App_Config(); $config->site_url = home_url( '/' ); $config->app_name = get_bloginfo( 'name' ); $config->branch = 'main'; return $config; } private function render_notice() { return Admin_Notice::render(); } private function render_form( Mobile_App_Config $config, Mobile_App_Store $store ) { $icon_url = $config->icon_attachment_id ? wp_get_attachment_image_url( $config->icon_attachment_id, 'medium' ) : ''; Admin_Layout::card_open( __( 'Android app settings', 'esipher' ), __( 'Wrap this site in a native Android app. Esipher generates a Capacitor project in a GitHub repo you own, and GitHub Actions builds an installable APK (for testing) and a signed AAB (for the Google Play Console).', 'esipher' ) ); echo '
'; wp_nonce_field( self::ACTION_SAVE_BUILD ); echo ''; echo ''; $this->text_row( 'esipher_app_name', __( 'App name', 'esipher' ), $config->app_name, __( 'Shown under the launcher icon.', 'esipher' ) ); $this->text_row( 'esipher_app_id', __( 'App ID', 'esipher' ), $config->app_id, __( 'Reverse-domain, lowercase, e.g. com.yourname.appname. Can\'t be changed after publishing to Play.', 'esipher' ), 'com.yourname.appname' ); $this->text_row( 'esipher_app_url', __( 'Site URL', 'esipher' ), '' !== $config->site_url ? $config->site_url : home_url( '/' ), __( 'The URL the app opens.', 'esipher' ) ); // Icon picker. echo ''; echo ''; echo ''; $this->text_row( 'esipher_app_repo', __( 'App repo', 'esipher' ), $config->repo ? $config->full_name() : '', __( 'Create a new GitHub repo with a readme file in it. paste the owner/repo name or paste the repo URL.', 'esipher' ), 'https://github.com/you/my-app' ); echo '
' . esc_html__( 'App icon', 'esipher' ) . ''; echo ''; echo ''; echo ' '; echo ''; echo '

' . esc_html__( 'Square PNG, at least 512×512 (1024×1024 recommended).', 'esipher' ) . '

'; echo ''; echo '

' . esc_html__( 'Status bar / accent.', 'esipher' ) . '

'; echo ''; echo '
'; submit_button( $store->get() ? __( 'Save & build Android', 'esipher' ) : __( 'Generate & build Android app', 'esipher' ) ); echo '
'; Admin_Layout::card_close(); } private function text_row( $name, $label, $value, $desc, $placeholder = '' ) { echo ''; echo ''; echo '

' . esc_html( $desc ) . '

'; } private function render_build_panel( Mobile_App_Config $config, Mobile_App_Store $store ) { if ( ! $store->get() ) { return; } Admin_Layout::card_open( __( 'Android build', 'esipher' ) ); echo ''; echo ''; echo ''; if ( '' !== $config->keystore_fingerprint ) { echo ''; } if ( '' !== $config->last_build_dispatched_at ) { echo ''; } echo '
' . esc_html__( 'Status', 'esipher' ) . '' . $this->status_pill( $config->status ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- status_pill() escapes. if ( '' !== $config->last_error ) { echo '

' . esc_html( $config->last_error ) . '

'; } echo '
' . esc_html__( 'Repo', 'esipher' ) . '' . esc_html( $config->full_name() ) . ' — Actions
' . esc_html__( 'Signing key (SHA-256)', 'esipher' ) . '' . esc_html( $config->keystore_fingerprint ) . '

' . esc_html__( 'Generated once and reused forever, Google Play binds your app to it.', 'esipher' ) . '

' . esc_html__( 'Last build started', 'esipher' ) . '' . esc_html( $config->last_build_dispatched_at ) . '
'; echo '

'; echo '' . esc_html__( 'Download latest build (.zip)', 'esipher' ) . ' '; echo '' . esc_html__( 'Rebuild Android', 'esipher' ) . ''; echo '

'; echo '

' . esc_html__( 'The .zip holds app-debug.apk (sideload to test) and app-release.aab (upload to the Play Console). This builds Android only; iOS is on its own tab.', 'esipher' ) . '

'; Admin_Layout::card_close(); } /** * A coloured pill for a build status. * * @param string $status Status key. * * @return string Escaped HTML. */ private function status_pill( $status ) { $tones = array( 'new' => '', 'building' => 'info', 'built' => 'ok', 'error' => 'err', ); return Admin_Layout::pill( $this->status_label( $status ), $tones[ $status ] ?? '' ); } private function status_label( $status ) { $map = array( 'new' => __( 'Not built yet', 'esipher' ), 'building' => __( 'Building on GitHub…', 'esipher' ), 'built' => __( 'Latest build ready', 'esipher' ), 'error' => __( 'Error', 'esipher' ), ); return $map[ $status ] ?? $status; } /** * The icon-picker JS, as a plain string for wp_add_inline_script(). * Runs after 'media-editor' (so wp.media exists) and defers its own * DOM lookups to DOMContentLoaded (so the form fields exist). * * @return string */ private function icon_picker_js() { $title = wp_json_encode( __( 'Choose an app icon', 'esipher' ) ); return implode( "\n", array( '( function () {', ' function init() {', ' var frame,', ' pick = document.getElementById( "esipher_app_icon_pick" ),', ' clear = document.getElementById( "esipher_app_icon_clear" ),', ' field = document.getElementById( "esipher_app_icon_id" ),', ' preview = document.getElementById( "esipher_app_icon_preview" );', ' if ( ! pick || ! window.wp || ! window.wp.media ) { return; }', ' pick.addEventListener( "click", function ( e ) {', ' e.preventDefault();', ' if ( frame ) { frame.open(); return; }', ' frame = wp.media( { title: ' . $title . ', library: { type: "image" }, multiple: false } );', ' frame.on( "select", function () {', ' var a = frame.state().get( "selection" ).first().toJSON();', ' field.value = a.id;', ' preview.src = ( a.sizes && a.sizes.medium ) ? a.sizes.medium.url : a.url;', ' preview.style.display = "block";', ' clear.style.display = "inline";', ' } );', ' frame.open();', ' } );', ' clear.addEventListener( "click", function ( e ) {', ' e.preventDefault();', ' field.value = "";', ' preview.src = "";', ' preview.style.display = "none";', ' clear.style.display = "none";', ' } );', ' }', ' if ( "loading" === document.readyState ) {', ' document.addEventListener( "DOMContentLoaded", init );', ' } else {', ' init();', ' }', '} )();', ) ); } public function handle_save_build() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_SAVE_BUILD ); $store = new Mobile_App_Store(); $config = $store->get() ?: new Mobile_App_Config(); $config->app_name = isset( $_POST['esipher_app_name'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_app_name'] ) ) : ''; $config->app_id = isset( $_POST['esipher_app_id'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['esipher_app_id'] ) ) ) : ''; $config->site_url = isset( $_POST['esipher_app_url'] ) ? esc_url_raw( wp_unslash( $_POST['esipher_app_url'] ) ) : ''; $config->icon_attachment_id = isset( $_POST['esipher_app_icon_id'] ) ? (int) $_POST['esipher_app_icon_id'] : 0; $config->theme_color = $this->sanitize_hex( isset( $_POST['esipher_app_theme_color'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_app_theme_color'] ) ) : '', '#111111' ); $config->background_color = $this->sanitize_hex( isset( $_POST['esipher_app_bg_color'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_app_bg_color'] ) ) : '', '#ffffff' ); $repo_raw = isset( $_POST['esipher_app_repo'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_app_repo'] ) ) : ''; $parsed = $this->parse_owner_repo( $repo_raw ); if ( null === $parsed ) { $this->redirect_notice( __( 'Could not read an owner/repo from that GitHub value.', 'esipher' ), true ); } $config->owner = $parsed['owner']; $config->repo = $parsed['repo']; $result = ( new Mobile_App_Service() )->build( $config, 'android' ); if ( is_wp_error( $result ) ) { $this->redirect_notice( $result->get_error_message(), true ); } $this->redirect_notice( __( 'Android app project pushed and a build started on GitHub. Use "Download latest build" once it finishes (usually a few minutes).', 'esipher' ) ); } public function handle_rebuild() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_REBUILD ); $config = ( new Mobile_App_Store() )->get(); if ( ! $config ) { $this->redirect_notice( __( 'Set up the app first.', 'esipher' ), true ); } $platforms = isset( $_GET['platforms'] ) && in_array( $_GET['platforms'], array( 'android', 'ios', 'both' ), true ) ? sanitize_key( wp_unslash( $_GET['platforms'] ) ) : 'both'; $result = ( new Mobile_App_Service() )->build( $config, $platforms ); $is_ios = 'ios' === $platforms; $this->redirect_notice( is_wp_error( $result ) ? $result->get_error_message() : __( 'Rebuild started on GitHub.', 'esipher' ), is_wp_error( $result ), $is_ios ? 'ios' : '' ); } public function handle_save_ios() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_SAVE_IOS ); $store = new Mobile_App_Store(); $config = $store->get(); if ( ! $config ) { $this->redirect_notice( __( 'Set up the app on the Android tab first.', 'esipher' ), true, 'ios' ); } $config->apple_team_id = isset( $_POST['esipher_apple_team_id'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_apple_team_id'] ) ) : ''; $config->asc_key_id = isset( $_POST['esipher_asc_key_id'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_asc_key_id'] ) ) : ''; $config->asc_issuer_id = isset( $_POST['esipher_asc_issuer_id'] ) ? sanitize_text_field( wp_unslash( $_POST['esipher_asc_issuer_id'] ) ) : ''; $p8 = isset( $_POST['esipher_asc_key_p8'] ) ? trim( (string) wp_unslash( $_POST['esipher_asc_key_p8'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- a PEM blob; sanitize_textarea_field would mangle it. Stored encrypted, never rendered. if ( '' !== $p8 ) { if ( false === strpos( $p8, 'BEGIN PRIVATE KEY' ) ) { $this->redirect_notice( __( 'That does not look like a .p8 key — it should start with "-----BEGIN PRIVATE KEY-----".', 'esipher' ), true, 'ios' ); } $store->save_asc_key( $p8 ); // A new API key means the previously provisioned certificate // + profile no longer apply — re-provision on the next build. $store->clear_ios_signing(); $config->ios_profile_name = ''; } if ( ! $config->ios_configured() || ! $store->has_asc_key() ) { $store->save( $config ); $this->redirect_notice( __( 'iOS settings saved. Fill in all four fields (Team ID, Key ID, Issuer ID and the .p8) to enable iOS builds.', 'esipher' ), false, 'ios' ); } $result = ( new Mobile_App_Service() )->build( $config, 'ios' ); if ( is_wp_error( $result ) ) { $this->redirect_notice( $result->get_error_message(), true, 'ios' ); } $this->redirect_notice( __( 'iOS settings saved and an iOS build started on GitHub. Grab the .ipa here once the macOS job finishes (usually 5-15 minutes).', 'esipher' ), false, 'ios' ); } public function handle_download() { if ( ! current_user_can( Admin_Page::CAPABILITY ) ) { wp_die( esc_html__( 'You do not have permission to do this.', 'esipher' ) ); } check_admin_referer( self::ACTION_DOWNLOAD ); $platform = isset( $_GET['platform'] ) && 'ios' === $_GET['platform'] ? 'ios' : 'android'; $zip = ( new Mobile_App_Service() )->download_latest_build( $platform ); if ( is_wp_error( $zip ) ) { $this->redirect_notice( $zip->get_error_message(), true, 'ios' === $platform ? 'ios' : 'android' ); } if ( ! is_file( $zip ) ) { $this->redirect_notice( __( 'The build download did not produce a file.', 'esipher' ), true, 'ios' === $platform ? 'ios' : 'android' ); } nocache_headers(); header( 'Content-Type: application/zip' ); header( 'Content-Disposition: attachment; filename="esipher-app-' . ( 'ios' === $platform ? 'ios' : 'build' ) . '.zip"' ); header( 'Content-Length: ' . filesize( $zip ) ); readfile( $zip ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a just-downloaded temp file to the browser. wp_delete_file( $zip ); exit; } /** * @param string $value * @param string $fallback * * @return string "#rrggbb" */ private function sanitize_hex( $value, $fallback ) { $value = sanitize_text_field( wp_unslash( (string) $value ) ); return preg_match( '/^#[0-9a-fA-F]{6}$/', $value ) ? strtolower( $value ) : $fallback; } private function redirect_notice( $message, $is_error = false, $tab = '' ) { Admin_Notice::flash( $message, $is_error ); wp_safe_redirect( $this->page_url( 'ios' === $tab ? array( 'tab' => 'ios' ) : array() ) ); exit; } /** * Accepts "owner/repo" or a github.com URL. * * @param string $input * * @return array{owner:string,repo:string}|null */ private function parse_owner_repo( $input ) { $input = trim( $input ); if ( preg_match( '#github\.com[/:]([^/]+)/([^/.\s]+)#i', $input, $m ) ) { return array( 'owner' => $m[1], 'repo' => $m[2] ); } if ( preg_match( '#^([\w.-]+)/([\w.-]+)$#', $input, $m ) ) { return array( 'owner' => $m[1], 'repo' => $m[2] ); } return null; } }