|\WP_Error * Map of repo-relative path => file. Text files carry raw * content (is_base64 false); the two PNGs carry base64. */ public function files( Mobile_App_Config $config, $include_ios = false ) { $icon_src = $this->load_icon_bytes( $config->icon_attachment_id ); if ( is_wp_error( $icon_src ) ) { return $icon_src; } $icon_png = $this->normalize_icon( $icon_src, $config->background_color ); $splash_png = $this->render_splash( $icon_png, $config->background_color ); $workflow_tpl = ESIPHER_PLUGIN_DIR . 'templates/capacitor-build.yml.tpl'; $workflow = is_file( $workflow_tpl ) ? (string) file_get_contents( $workflow_tpl ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading this plugin's own bundled template. if ( '' === $workflow ) { return new \WP_Error( 'esipher_app_template_missing', __( 'Esipher\'s bundled app-build workflow template is missing — reinstall the plugin.', 'esipher' ) ); } $files = array( 'package.json' => $this->text( $this->package_json( $config, $include_ios ) ), 'capacitor.config.json' => $this->text( $this->capacitor_config( $config ) ), 'www/index.html' => $this->text( $this->fallback_page( $config ) ), 'resources/icon.png' => $this->binary( $icon_png ), 'resources/splash.png' => $this->binary( $splash_png ), 'resources/splash-dark.png' => $this->binary( $splash_png ), '.github/workflows/build-app.yml' => $this->text( $workflow ), '.gitignore' => $this->text( "node_modules/\nandroid/\nios/\n_out/\n_out_ios/\n*.log\n" ), 'README.md' => $this->text( $this->readme( $config ) ), ); // The iOS job in build-app.yml only runs when this marker file // is present (it's the one thing a GitHub Actions `if:` can // cheaply check). exportOptions.plist drives xcodebuild's // -exportArchive step. if ( $include_ios ) { $files['.esipher-ios.json'] = $this->text( wp_json_encode( array( 'teamId' => $config->apple_team_id, 'bundleId' => $config->app_id, ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n" ); $files['exportOptions.plist'] = $this->text( $this->export_options_plist( $config ) ); } return $files; } /** * @param string $raw * * @return array{content:string,is_base64:bool} */ private function text( $raw ) { return array( 'content' => $raw, 'is_base64' => false ); } /** * @param string $bytes * * @return array{content:string,is_base64:bool} */ private function binary( $bytes ) { return array( 'content' => base64_encode( $bytes ), 'is_base64' => true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- packaging binary image bytes for the Git blobs API. } /** * @param Mobile_App_Config $config * * @return string */ private function package_json( Mobile_App_Config $config, $include_ios = false ) { $dependencies = array( '@capacitor/core' => self::CAP_VERSION, '@capacitor/android' => self::CAP_VERSION, ); if ( $include_ios ) { $dependencies['@capacitor/ios'] = self::CAP_VERSION; } return wp_json_encode( array( 'name' => $config->package_slug(), 'version' => '1.0.0', 'private' => true, 'description' => 'WebView wrapper app for ' . $config->site_url . ' — generated by Esipher.', 'dependencies' => $dependencies, 'devDependencies' => array( '@capacitor/cli' => self::CAP_VERSION, '@capacitor/assets' => self::ASSETS_VERSION, ), ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"; } /** * @param Mobile_App_Config $config * * @return string */ private function capacitor_config( Mobile_App_Config $config ) { $is_https = 0 === stripos( $config->site_url, 'https://' ); $conf = array( 'appId' => $config->app_id, 'appName' => $config->app_name, 'webDir' => 'www', 'server' => array( 'url' => $config->site_url, 'cleartext' => ! $is_https, 'androidScheme' => $is_https ? 'https' : 'http', 'errorPath' => 'index.html', ), 'android' => array( 'backgroundColor' => $config->background_color, ), 'backgroundColor' => $config->background_color, ); return wp_json_encode( $conf, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"; } /** * Shown only when the device is offline or the site is unreachable * (Capacitor's server.errorPath). Kept deliberately tiny and * dependency-free. * * @param Mobile_App_Config $config * * @return string */ private function fallback_page( Mobile_App_Config $config ) { $template = ESIPHER_PLUGIN_DIR . 'templates/app-fallback.html.tpl'; $html = is_file( $template ) ? (string) file_get_contents( $template ) : ''; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading this plugin's own bundled template. return strtr( $html, array( '{{NAME}}' => esc_html( $config->app_name ), '{{BG}}' => esc_attr( $config->background_color ), '{{FG}}' => esc_attr( $config->theme_color ), ) ); } /** * @param Mobile_App_Config $config * * @return string */ private function readme( Mobile_App_Config $config ) { return implode( "\n", array( '# ' . $config->app_name . ' - mobile app', '', 'A [Capacitor](https://capacitorjs.com/) WebView wrapper around', '<' . $config->site_url . '>, generated and maintained by the Esipher', 'plugin. Don\'t edit these files by hand - change the app\'s settings under', 'Esipher -> Mobile App in wp-admin and Esipher re-pushes them here.', '', '## Builds', '', 'The bundled workflow (.github/workflows/build-app.yml) is run from the', 'plugin. It outputs an "esipher-app" artifact (app-debug.apk to sideload,', 'app-release.aab for the Google Play Console) and, when iOS is configured,', 'an "esipher-app-ios" artifact (App.ipa for App Store Connect).', '', 'Grab them from the newest green run under the repo\'s Actions tab, or via', 'Esipher -> Mobile App -> Download latest build.', '', '## Signing', '', 'The workflow signs with keys Esipher generated and stored as repo', 'secrets. Keep them - the app stores permanently bind your app to them.', '', 'Repo: ' . $config->full_name(), '', ) ); } /** * The plist xcodebuild's -exportArchive step consumes. Automatic * signing + the App Store Connect API key (passed on the xcodebuild * command line by the workflow) means no certificate or provisioning * profile has to be prepared by hand. * * @param Mobile_App_Config $config * * @return string */ private function export_options_plist( Mobile_App_Config $config ) { $team = esc_html( $config->apple_team_id ); $bundle = esc_html( $config->app_id ); $profile = esc_html( $config->ios_profile_name ); return implode( "\n", array( '', '', '', '', ' method', ' app-store', ' teamID', ' ' . $team . '', ' signingStyle', ' manual', ' signingCertificate', ' Apple Distribution', ' provisioningProfiles', ' ', ' ' . $bundle . '', ' ' . $profile . '', ' ', ' destination', ' export', ' generateAppStoreInformation', ' ', ' stripSwiftSymbols', ' ', '', '', '', ) ); } /** * @param int $attachment_id * * @return string|\WP_Error Raw image bytes. */ private function load_icon_bytes( $attachment_id ) { $attachment_id = (int) $attachment_id; if ( $attachment_id <= 0 ) { return new \WP_Error( 'esipher_app_no_icon', __( 'Choose an app icon first (a square PNG, at least 512×512).', 'esipher' ) ); } $path = get_attached_file( $attachment_id ); if ( ! $path || ! is_file( $path ) ) { return new \WP_Error( 'esipher_app_icon_missing', __( 'The selected app icon file could not be found in the media library.', 'esipher' ) ); } $bytes = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local media-library file chosen by the admin. if ( false === $bytes || '' === $bytes ) { return new \WP_Error( 'esipher_app_icon_unreadable', __( 'The selected app icon could not be read.', 'esipher' ) ); } $size = @getimagesizefromstring( $bytes ); if ( false === $size ) { return new \WP_Error( 'esipher_app_icon_not_image', __( 'The selected app icon is not a readable image.', 'esipher' ) ); } if ( $size[0] < 512 || $size[1] < 512 ) { return new \WP_Error( 'esipher_app_icon_small', sprintf( /* translators: 1: width, 2: height. */ __( 'The app icon is %1$d×%2$d — it needs to be at least 512×512 (1024×1024 recommended).', 'esipher' ), $size[0], $size[1] ) ); } return $bytes; } /** * Produce a square 1024×1024 PNG: the source scaled to fit and * centred on a background-colour canvas (so a non-square source * isn't stretched). Falls back to the raw bytes if GD is missing. * * @param string $bytes Source image bytes. * @param string $bg "#rrggbb". * * @return string PNG bytes. */ private function normalize_icon( $bytes, $bg ) { if ( ! function_exists( 'imagecreatefromstring' ) ) { return $bytes; } $src = @imagecreatefromstring( $bytes ); if ( false === $src ) { return $bytes; } $out = $this->blank_canvas( self::ICON_SIZE, self::ICON_SIZE, $bg ); $sw = imagesx( $src ); $sh = imagesy( $src ); $scale = min( self::ICON_SIZE / $sw, self::ICON_SIZE / $sh ); $dw = (int) round( $sw * $scale ); $dh = (int) round( $sh * $scale ); $dx = (int) round( ( self::ICON_SIZE - $dw ) / 2 ); $dy = (int) round( ( self::ICON_SIZE - $dh ) / 2 ); imagecopyresampled( $out, $src, $dx, $dy, 0, 0, $dw, $dh, $sw, $sh ); $png = $this->png_bytes( $out ); imagedestroy( $src ); imagedestroy( $out ); return '' !== $png ? $png : $bytes; } /** * A 2732×2732 splash: background colour with the icon centred at * ~24% width. * * @param string $icon_png Normalised icon PNG bytes. * @param string $bg "#rrggbb". * * @return string PNG bytes. */ private function render_splash( $icon_png, $bg ) { if ( ! function_exists( 'imagecreatefromstring' ) ) { return $icon_png; } $canvas = $this->blank_canvas( self::SPLASH_SIZE, self::SPLASH_SIZE, $bg ); $icon = @imagecreatefromstring( $icon_png ); if ( false !== $icon ) { $target = (int) round( self::SPLASH_SIZE * 0.24 ); $pos = (int) round( ( self::SPLASH_SIZE - $target ) / 2 ); imagecopyresampled( $canvas, $icon, $pos, $pos, 0, 0, $target, $target, imagesx( $icon ), imagesy( $icon ) ); imagedestroy( $icon ); } $png = $this->png_bytes( $canvas ); imagedestroy( $canvas ); return '' !== $png ? $png : $icon_png; } /** * @param int $w * @param int $h * @param string $hex "#rrggbb" * * @return \GdImage|resource */ private function blank_canvas( $w, $h, $hex ) { $img = imagecreatetruecolor( $w, $h ); list( $r, $g, $b ) = $this->hex_to_rgb( $hex ); imagefilledrectangle( $img, 0, 0, $w, $h, imagecolorallocate( $img, $r, $g, $b ) ); return $img; } /** * @param \GdImage|resource $img * * @return string PNG bytes, or '' on failure. */ private function png_bytes( $img ) { ob_start(); $ok = imagepng( $img ); $out = (string) ob_get_clean(); return $ok ? $out : ''; } /** * @param string $hex "#rrggbb" (a leading # optional). * * @return array{0:int,1:int,2:int} */ private function hex_to_rgb( $hex ) { $hex = ltrim( (string) $hex, '#' ); if ( 3 === strlen( $hex ) ) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) { return array( 255, 255, 255 ); } return array( hexdec( substr( $hex, 0, 2 ) ), hexdec( substr( $hex, 2, 2 ) ), hexdec( substr( $hex, 4, 2 ) ), ); } }