From e6930011abee859db9e5a6a5d4a3b65c70161d18 Mon Sep 17 00:00:00 2001 From: Ronny Grobel Date: Mon, 20 Apr 2026 21:12:48 +0200 Subject: [PATCH] Initial commit: KGV-PWA plugin with manifest, service worker and install prompt --- assets/css/kgv-pwa.css | 84 ++++++ assets/js/pwa-register.js | 124 +++++++++ assets/js/service-worker.js | 117 ++++++++ kgv-pwa.php | 519 ++++++++++++++++++++++++++++++++++++ readme.txt | 47 ++++ 5 files changed, 891 insertions(+) create mode 100644 assets/css/kgv-pwa.css create mode 100644 assets/js/pwa-register.js create mode 100644 assets/js/service-worker.js create mode 100644 kgv-pwa.php create mode 100644 readme.txt diff --git a/assets/css/kgv-pwa.css b/assets/css/kgv-pwa.css new file mode 100644 index 0000000..b0f9464 --- /dev/null +++ b/assets/css/kgv-pwa.css @@ -0,0 +1,84 @@ +/** + * KGV PWA – Install-Banner + */ + +#kgv-pwa-install-banner { + position: fixed; + bottom: -80px; + left: 0; + right: 0; + z-index: 99999; + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + background: #1b5e20; + color: #fff; + font-family: inherit; + font-size: 14px; + box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.25); + transition: bottom 0.3s ease; +} + +#kgv-pwa-install-banner.kgv-pwa-banner--visible { + bottom: 0; +} + +.kgv-pwa-banner__text { + flex: 1; +} + +.kgv-pwa-banner__install { + flex-shrink: 0; + padding: 7px 18px; + background: #fff; + color: #1b5e20; + border: none; + border-radius: 4px; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} + +.kgv-pwa-banner__install:hover, +.kgv-pwa-banner__install:focus { + background: #e8f5e9; + outline: 2px solid #fff; + outline-offset: 2px; +} + +.kgv-pwa-banner__close { + flex-shrink: 0; + background: transparent; + border: none; + color: #fff; + font-size: 20px; + line-height: 1; + cursor: pointer; + padding: 0 4px; + opacity: 0.8; +} + +.kgv-pwa-banner__close:hover, +.kgv-pwa-banner__close:focus { + opacity: 1; + outline: 1px solid rgba(255, 255, 255, 0.6); + border-radius: 2px; +} + +/* Schmale Bildschirme */ +@media ( max-width: 480px ) { + #kgv-pwa-install-banner { + flex-wrap: wrap; + gap: 8px; + } + + .kgv-pwa-banner__text { + flex-basis: 100%; + } + + .kgv-pwa-banner__install { + flex: 1; + } +} diff --git a/assets/js/pwa-register.js b/assets/js/pwa-register.js new file mode 100644 index 0000000..7baf4f1 --- /dev/null +++ b/assets/js/pwa-register.js @@ -0,0 +1,124 @@ +/** + * KGV PWA – Service Worker Registrierung & Install-Prompt + */ + +( function () { + 'use strict'; + + var cfg = window.kgvPwaConfig || {}; + var swUrl = cfg.swUrl || '/sw.js'; + var scope = cfg.scope || '/'; + + // ------------------------------------------------------------------ + // Service Worker registrieren + // ------------------------------------------------------------------ + if ( 'serviceWorker' in navigator ) { + window.addEventListener( 'load', function () { + navigator.serviceWorker + .register( swUrl, { scope: scope } ) + .catch( function ( err ) { + if ( window.console && console.warn ) { + console.warn( 'KGV PWA: Service Worker konnte nicht registriert werden.', err ); + } + } ); + } ); + } + + // ------------------------------------------------------------------ + // Install-Banner + // ------------------------------------------------------------------ + var deferredPrompt = null; + var banner = null; + + /** + * Banner-Element erzeugen und in den DOM einhängen. + */ + function createBanner() { + banner = document.createElement( 'div' ); + banner.id = 'kgv-pwa-install-banner'; + banner.setAttribute( 'role', 'region' ); + banner.setAttribute( 'aria-label', 'App installieren' ); + banner.innerHTML = + 'Diese Seite als App installieren' + + '' + + ''; + + document.body.appendChild( banner ); + + banner.querySelector( '.kgv-pwa-banner__install' ).addEventListener( 'click', triggerInstall ); + banner.querySelector( '.kgv-pwa-banner__close' ).addEventListener( 'click', dismissBanner ); + } + + function showBanner() { + if ( ! banner ) { + createBanner(); + } + banner.classList.add( 'kgv-pwa-banner--visible' ); + } + + function dismissBanner() { + if ( banner ) { + banner.classList.remove( 'kgv-pwa-banner--visible' ); + } + // Nicht mehr anzeigen bis zum nächsten Tag + try { + sessionStorage.setItem( 'kgv_pwa_install_dismissed', '1' ); + } catch ( e ) {} + } + + /** + * Nativen Browser-Installationsdialog öffnen. + * Kann auch von einem beliebigen Button auf der Seite aufgerufen werden: + * document.dispatchEvent( new CustomEvent('kgv-pwa-install') ); + */ + function triggerInstall() { + if ( ! deferredPrompt ) { + if ( isIos() ) { + window.alert( cfg.iosInstallNotice || 'Auf iPhone/iPad: Teilen > Zum Home-Bildschirm.' ); + } + return; + } + deferredPrompt.prompt(); + deferredPrompt.userChoice.then( function ( result ) { + deferredPrompt = null; + dismissBanner(); + if ( window.console && console.info ) { + console.info( 'KGV PWA: Installationsentscheidung:', result.outcome ); + } + } ); + } + + // Externer Aufruf via Custom Event ermöglichen + document.addEventListener( 'kgv-pwa-install', triggerInstall ); + + // ------------------------------------------------------------------ + // beforeinstallprompt – Browser signalisiert Installierbarkeit + // ------------------------------------------------------------------ + window.addEventListener( 'beforeinstallprompt', function ( e ) { + // Standard-Mini-Infobar des Browsers unterdrücken + e.preventDefault(); + deferredPrompt = e; + + // Banner nicht zeigen, wenn der Nutzer ihn in dieser Sitzung bereits weggeklickt hat + try { + if ( sessionStorage.getItem( 'kgv_pwa_install_dismissed' ) ) { + return; + } + } catch ( err ) {} + + showBanner(); + } ); + + // ------------------------------------------------------------------ + // appinstalled – nach erfolgreicher Installation Banner verbergen + // ------------------------------------------------------------------ + window.addEventListener( 'appinstalled', function () { + deferredPrompt = null; + dismissBanner(); + } ); + + function isIos() { + return /iphone|ipad|ipod/i.test( window.navigator.userAgent || '' ); + } + +} )(); diff --git a/assets/js/service-worker.js b/assets/js/service-worker.js new file mode 100644 index 0000000..2801390 --- /dev/null +++ b/assets/js/service-worker.js @@ -0,0 +1,117 @@ +/** + * KGV PWA – Service Worker + * + * Strategie: + * - HTML-Seiten: Network-first (frische Inhalte, Fallback auf Cache) + * - Statische Assets (CSS/JS/Bilder/Fonts): Cache-first + */ + +'use strict'; + +const CACHE_NAME = 'kgv-pwa-v1'; +const OFFLINE_PAGE = '/'; + +const PRECACHE_URLS = [ + '/', +]; + +// ----------------------------------------------------------------------- +// Install: Precache-Ressourcen laden +// ----------------------------------------------------------------------- +self.addEventListener( 'install', function ( event ) { + event.waitUntil( + caches.open( CACHE_NAME ).then( function ( cache ) { + return cache.addAll( PRECACHE_URLS ); + } ) + ); + self.skipWaiting(); +} ); + +// ----------------------------------------------------------------------- +// Activate: Alte Caches löschen +// ----------------------------------------------------------------------- +self.addEventListener( 'activate', function ( event ) { + event.waitUntil( + caches.keys().then( function ( keys ) { + return Promise.all( + keys + .filter( function ( key ) { return key !== CACHE_NAME; } ) + .map( function ( key ) { return caches.delete( key ); } ) + ); + } ) + ); + self.clients.claim(); +} ); + +// ----------------------------------------------------------------------- +// Fetch: Anfragen abfangen +// ----------------------------------------------------------------------- +self.addEventListener( 'fetch', function ( event ) { + var request = event.request; + + // Nur GET-Anfragen behandeln + if ( request.method !== 'GET' ) { + return; + } + + // Nur Anfragen gleichen Ursprungs behandeln + if ( ! request.url.startsWith( self.location.origin ) ) { + return; + } + + var url = new URL( request.url ); + + // Admin-Bereich und WP-Cron ausschließen + if ( + url.pathname.startsWith( '/wp-admin' ) || + url.pathname.startsWith( '/wp-cron' ) || + url.pathname.includes( 'wp-login' ) + ) { + return; + } + + // HTML-Navigation: Network-first + if ( request.mode === 'navigate' ) { + event.respondWith( + fetch( request ) + .then( function ( response ) { + // Erfolgreiche Antwort im Cache speichern + if ( response && response.status === 200 ) { + var clone = response.clone(); + caches.open( CACHE_NAME ).then( function ( cache ) { + cache.put( request, clone ); + } ); + } + return response; + } ) + .catch( function () { + // Offline-Fallback: gecachte Startseite + return caches.match( request ).then( function ( cached ) { + return cached || caches.match( OFFLINE_PAGE ); + } ); + } ) + ); + return; + } + + // Statische Assets: Cache-first + if ( /\.(css|js|png|jpg|jpeg|gif|svg|webp|woff2?|ttf|eot|ico)(\?.*)?$/.test( url.pathname ) ) { + event.respondWith( + caches.match( request ).then( function ( cached ) { + if ( cached ) { + return cached; + } + return fetch( request ).then( function ( response ) { + if ( response && response.status === 200 ) { + var clone = response.clone(); + caches.open( CACHE_NAME ).then( function ( cache ) { + cache.put( request, clone ); + } ); + } + return response; + } ); + } ) + ); + return; + } +} ); diff --git a/kgv-pwa.php b/kgv-pwa.php new file mode 100644 index 0000000..9388006 --- /dev/null +++ b/kgv-pwa.php @@ -0,0 +1,519 @@ +add_rewrite_rules(); + flush_rewrite_rules(); + update_option( self::REWRITE_VERSION_OPTION, self::VERSION ); + + if ( false === get_option( self::OPTION_KEY ) ) { + add_option( self::OPTION_KEY, self::default_settings() ); + } + } + + public static function deactivate() { + flush_rewrite_rules(); + delete_option( self::REWRITE_VERSION_OPTION ); + } + + public function maybe_flush_rewrite_rules() { + $installed_version = get_option( self::REWRITE_VERSION_OPTION, '' ); + + if ( self::VERSION === $installed_version ) { + return; + } + + flush_rewrite_rules( false ); + update_option( self::REWRITE_VERSION_OPTION, self::VERSION ); + } + + // ------------------------------------------------------------------------- + // Rewrite Rules & Query Vars + // ------------------------------------------------------------------------- + + public function add_rewrite_rules() { + add_rewrite_rule( '^manifest\.json$', 'index.php?kgv_pwa=manifest', 'top' ); + add_rewrite_rule( '^sw\.js$', 'index.php?kgv_pwa=sw', 'top' ); + } + + public function add_query_vars( $vars ) { + $vars[] = 'kgv_pwa'; + + return $vars; + } + + // ------------------------------------------------------------------------- + // Manifest Endpoint + // ------------------------------------------------------------------------- + + public function handle_manifest() { + if ( 'manifest' !== get_query_var( 'kgv_pwa' ) ) { + return; + } + + $settings = $this->get_settings(); + + $manifest = array( + 'name' => $settings['app_name'], + 'short_name' => $settings['short_name'], + 'description' => $settings['description'], + 'start_url' => home_url( '/' ), + 'scope' => home_url( '/' ), + 'display' => $settings['display'], + 'background_color' => $settings['background_color'], + 'theme_color' => $settings['theme_color'], + 'lang' => str_replace( '_', '-', get_locale() ), + 'icons' => $this->get_manifest_icons( $settings ), + ); + + header( 'Content-Type: application/manifest+json; charset=utf-8' ); + header( 'Cache-Control: max-age=3600' ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + echo wp_json_encode( $manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); + exit; + } + + private function get_manifest_icons( $settings ) { + $icons = array(); + + foreach ( array( '192', '512' ) as $size ) { + $key = 'icon_' . $size; + if ( ! empty( $settings[ $key ] ) ) { + $icons[] = array( + 'src' => esc_url_raw( $settings[ $key ] ), + 'sizes' => $size . 'x' . $size, + 'type' => 'image/png', + 'purpose' => 'any maskable', + ); + } + } + + // Fallback: WordPress-Site-Icon + if ( empty( $icons ) ) { + $site_icon_512 = get_site_icon_url( 512 ); + $site_icon_192 = get_site_icon_url( 192 ); + + if ( $site_icon_512 ) { + $icons[] = array( + 'src' => $site_icon_512, + 'sizes' => '512x512', + 'type' => 'image/png', + 'purpose' => 'any', + ); + } + + if ( $site_icon_192 ) { + $icons[] = array( + 'src' => $site_icon_192, + 'sizes' => '192x192', + 'type' => 'image/png', + 'purpose' => 'any', + ); + } + } + + return $icons; + } + + // ------------------------------------------------------------------------- + // Service Worker Endpoint + // ------------------------------------------------------------------------- + + public function handle_service_worker() { + if ( 'sw' !== get_query_var( 'kgv_pwa' ) ) { + return; + } + + $sw_file = plugin_dir_path( __FILE__ ) . 'assets/js/service-worker.js'; + + if ( ! file_exists( $sw_file ) ) { + status_header( 404 ); + exit; + } + + header( 'Content-Type: application/javascript; charset=utf-8' ); + header( 'Service-Worker-Allowed: /' ); + header( 'Cache-Control: no-cache, no-store, must-revalidate' ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + readfile( $sw_file ); + exit; + } + + // ------------------------------------------------------------------------- + // Frontend: Head Tags & Script + // ------------------------------------------------------------------------- + + public function output_head_tags() { + $settings = $this->get_settings(); + $app_name = esc_attr( $settings['app_name'] ); + $theme_hex = esc_attr( $settings['theme_color'] ); + $icon_url = ! empty( $settings['icon_192'] ) ? esc_url( $settings['icon_192'] ) : ''; + + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + echo '' . "\n"; + + if ( $icon_url ) { + echo '' . "\n"; + } + } + + private function get_manifest_url() { + // Query-URL funktioniert auch dann, wenn Pretty-Rewrite noch nicht aktiv ist. + return home_url( '/?kgv_pwa=manifest' ); + } + + private function get_service_worker_url() { + // Query-URL funktioniert auch dann, wenn Pretty-Rewrite noch nicht aktiv ist. + return home_url( '/?kgv_pwa=sw' ); + } + + public function enqueue_assets() { + $sw_url = $this->get_service_worker_url(); + $scope = trailingslashit( home_url( '/' ) ); + + wp_enqueue_style( + 'kgv-pwa', + plugin_dir_url( __FILE__ ) . 'assets/css/kgv-pwa.css', + array(), + self::VERSION + ); + + wp_enqueue_script( + 'kgv-pwa-register', + plugin_dir_url( __FILE__ ) . 'assets/js/pwa-register.js', + array(), + self::VERSION, + true + ); + + wp_localize_script( + 'kgv-pwa-register', + 'kgvPwaConfig', + array( + 'swUrl' => esc_url_raw( $sw_url ), + 'scope' => esc_url_raw( $scope ), + 'iosInstallNotice' => __( 'Auf iPhone/iPad: Im Browser auf Teilen tippen und dann "Zum Home-Bildschirm" waehlen.', 'kgv-pwa' ), + ) + ); + } + + /** + * Shortcode [kgv_pwa_install_button] – rendert einen Installations-Button. + * Kann in Seiten, Posts und Widgets verwendet werden. + * + * Attribute: + * label – Beschriftung des Buttons (Standard: "App installieren") + * class – Zusätzliche CSS-Klassen + */ + public function render_install_button( $atts ) { + $atts = shortcode_atts( + array( + 'label' => __( 'App installieren', 'kgv-pwa' ), + 'class' => '', + ), + $atts, + 'kgv_pwa_install_button' + ); + + $classes = 'kgv-pwa-install-btn'; + if ( ! empty( $atts['class'] ) ) { + $classes .= ' ' . sanitize_html_class( $atts['class'] ); + } + + return sprintf( + '', + esc_attr( $classes ), + esc_html( $atts['label'] ) + ); + } + + // ------------------------------------------------------------------------- + // Admin Settings + // ------------------------------------------------------------------------- + + public function add_settings_page() { + add_options_page( + __( 'KGV PWA', 'kgv-pwa' ), + __( 'KGV PWA', 'kgv-pwa' ), + 'manage_options', + 'kgv-pwa', + array( $this, 'render_settings_page' ) + ); + } + + public function register_settings() { + register_setting( + 'kgv_pwa_settings_group', + self::OPTION_KEY, + array( $this, 'sanitize_settings' ) + ); + } + + public function sanitize_settings( $input ) { + $defaults = self::default_settings(); + $output = array(); + + $output['app_name'] = isset( $input['app_name'] ) + ? sanitize_text_field( $input['app_name'] ) + : $defaults['app_name']; + + $output['short_name'] = isset( $input['short_name'] ) + ? sanitize_text_field( $input['short_name'] ) + : $defaults['short_name']; + + $output['description'] = isset( $input['description'] ) + ? sanitize_textarea_field( $input['description'] ) + : ''; + + $output['theme_color'] = isset( $input['theme_color'] ) + && preg_match( '/^#[0-9a-fA-F]{6}$/', $input['theme_color'] ) + ? $input['theme_color'] + : $defaults['theme_color']; + + $output['background_color'] = isset( $input['background_color'] ) + && preg_match( '/^#[0-9a-fA-F]{6}$/', $input['background_color'] ) + ? $input['background_color'] + : $defaults['background_color']; + + $valid_display = array( 'standalone', 'fullscreen', 'minimal-ui', 'browser' ); + $output['display'] = isset( $input['display'] ) + && in_array( $input['display'], $valid_display, true ) + ? $input['display'] + : $defaults['display']; + + $output['icon_192'] = isset( $input['icon_192'] ) ? esc_url_raw( $input['icon_192'] ) : ''; + $output['icon_512'] = isset( $input['icon_512'] ) ? esc_url_raw( $input['icon_512'] ) : ''; + + return $output; + } + + private static function default_settings() { + return array( + 'app_name' => get_bloginfo( 'name' ), + 'short_name' => get_bloginfo( 'name' ), + 'description' => get_bloginfo( 'description' ), + 'theme_color' => '#2e7d32', + 'background_color' => '#ffffff', + 'display' => 'standalone', + 'icon_192' => '', + 'icon_512' => '', + ); + } + + public function get_settings() { + $settings = get_option( self::OPTION_KEY, array() ); + + if ( ! is_array( $settings ) ) { + $settings = array(); + } + + return wp_parse_args( $settings, self::default_settings() ); + } + + public function render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $settings = $this->get_settings(); + $option_prefix = esc_attr( self::OPTION_KEY ); + $display_modes = array( + 'standalone' => 'Standalone', + 'fullscreen' => 'Fullscreen', + 'minimal-ui' => 'Minimal UI', + 'browser' => 'Browser', + ); + ?> +
+

+ +

+ + + get_manifest_url() ); ?> + +  |  + + + get_service_worker_url() ); ?> + +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+