Mert Özen Her Satırda Daha İleri
Frontend Browser API'leri ve PWA — #06

PWA'yı Kurulabilir Yapmak: Web App Manifest, Install Akışı, navigator.install ve App Badging

Mert Özen 15 Kas 2026 16 dk 9 görüntülenme
PWA'yı Kurulabilir Yapmak: Web App Manifest, Install Akışı, navigator.install ve App Badging

Bir PWA'yı kurulabilir yapan şey manifest'tir: id'yi bir kez yazıp sabitle, any ve maskable icon'ları ayır, install teklifini Chromium'da beforeinstallprompt ile kullanıcı bir değer gördükten sonra göster ve Safari'de Ana Ekrana Ekle talimatı ver. Chromium 158 ve WebKit'te kimlik hesabını, kurulabilirlik kontrolünü, navigator.install()'un hatalarını ve okunmamış siparişleri gösteren App Badging'i gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), @angular/service-worker 22.2.6, Node.js 24 ve Playwright 1.65 ile denenmiştir; tarayıcı çıktıları Playwright'ın Chromium 158 ve WebKit 27.2 build'lerinden, production build üzerinde alınmıştır. Destek bilgileri MDN'in (Mozilla'nın web platformu dokümantasyonu) browser-compat-data (BCD) 8.1.9 ve web-features 3.46 verilerinden okunmuştur; Firefox ve gerçek Safari/iOS bu ortamda çalıştırılamadı, onlarla ilgili satırlar bu verilerden ve tarayıcı üreticilerinin dokümanlarından gelir.

Önce Tahmin Et

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) Angular istemcisi bir önceki yazıda offline çalışır hâle geldi. ng add @angular/pwa'nın ürettiği manifest'e o yazıda dokunmamıştık; şu satırlar ondan:

{
  "name": "bookstore-web",
  "short_name": "bookstore-web",
  "display": "standalone",
  "scope": "./",
  "start_url": "./",
  ...
}

Pazarlama ekibi, uygulamanın ana ekrandan mı yoksa tarayıcıdan mı açıldığını görmek için start_url'i "/?source=pwa" yapmak istiyor. Masum bir değişiklik gibi görünüyor. Uygulamayı daha önce kurmuş kullanıcılar için ne değişir?

Bu yazıda Bookstore'u kurulabilir bir uygulamaya çeviriyoruz: manifest'in kimliği ve alanları, maskable icon'lar, Chromium'da beforeinstallprompt ile kullanıcıyı rahatsız etmeyen bir install teklifi, yeni Web Install API'si (navigator.install()), Safari ve iOS'taki farklı yol ve okunmamış sipariş güncellemelerini uygulama icon'unda gösteren App Badging. Kurulabilir PWA (Progressive Web App; tarayıcıdan kurulup kendi penceresinde açılan web uygulaması) için gereken her şey bir JSON dosyası ve birkaç satır TypeScript.

Manifest'in Kimliği: id, start_url ve scope

Tarayıcı kurulu bir uygulamayı manifest'teki id ile tanır. id yoksa kimlik start_url'den hesaplanır. Chromium'un DevTools protokolündeki Page.getAppId komutu hesaplanan kimliği ve önerilen id değerini veriyor; ng add manifest'ini önce olduğu gibi, sonra yalnız start_url'i değiştirerek sorguladım:

ng add: appId=http://localhost:5282/ recommendedId=/
  installability errors: none
ng add + start_url "/?source=pwa": appId=http://localhost:5282/?source=pwa recommendedId=/?source=pwa
  installability errors: none

Tahminin cevabı burada: start_url değişince uygulamanın kimliği de değişti. Tarayıcı açısından bu artık başka bir uygulama; eski kurulumlar eski kimlikle kalır, yeni manifest'i güncelleme olarak almaz ve aynı kullanıcı iki ayrı Bookstore kurabilir. Çözüm id'yi bir kez, açıkça yazıp bir daha değiştirmemek. id origin'e göre çözülüyor; "/" demek http://localhost:5282/ demek, yani mevcut kurulumların kimliğiyle aynı. Bookstore'un yeni manifest'i:

{
  "id": "/",
  "name": "Bookstore",
  "short_name": "Bookstore",
  "description": "Browse the catalog, keep a reading list and follow your orders.",
  "start_url": "/?source=pwa",
  "scope": "/",
  "display": "standalone",
  "theme_color": "#1e3a5f",
  "background_color": "#ffffff",
  "icons": [
    { "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
    { "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
    { "src": "icons/maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
    { "src": "icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
  ],
  "screenshots": [
    {
      "src": "screenshots/catalog-wide.png",
      "sizes": "1280x800",
      "type": "image/png",
      "form_factor": "wide",
      "label": "The Bookstore catalog"
    },
    {
      "src": "screenshots/catalog-narrow.png",
      "sizes": "540x960",
      "type": "image/png",
      "form_factor": "narrow",
      "label": "The Bookstore catalog on a phone"
    }
  ],
  "shortcuts": [
    { "name": "My orders", "url": "/orders?source=shortcut", "icons": [{ "src": "icons/icon-192.png", "sizes": "192x192" }] },
    { "name": "Settings", "url": "/settings?source=shortcut" }
  ]
}

scope uygulamanın sınırını çiziyor: kurulu pencere bu path altındaki adresleri kendi içinde açar, dışına çıkan bir link tarayıcıda (ya da pencerenin üstünde bir araç çubuğuyla) açılır. ng add'in ürettiği "./" değerleri manifest dosyasının adresine göre çözülüyor; uygulama kökte yayınlandığı sürece "/" ile aynı. Angular uygulamasını /store/ gibi bir alt path'te yayınlıyorsan scope, start_url ve id'yi buna göre yaz ve id'nin yayın adresi değişse bile değişmeyeceğini hesaba kat. start_url'deki ?source=pwa artık güvenli: kimliği id taşıdığı için analytics parametresi değişse de kurulum aynı uygulama olarak kalıyor. Sunucu kapalıyken /?source=pwa ve shortcut adresi /orders?source=shortcut'u da açtım; ikisi de Angular Service Worker'ın (NGSW) cache'indeki index.html ile açıldı ve Catalog ile My orders sayfalarını gösterdi.

Icon'lar: any ve maskable

ng add sekiz boyutta icon üretip hepsine "purpose": "maskable any" yazıyor. Bu iki amaç birbiriyle çelişiyor. any icon olduğu gibi gösterilir; köşeleri saydam olabilir, logo kenarlara kadar uzanabilir. maskable icon ise işletim sisteminin maskesiyle (Android'de daire, yuvarlatılmış kare ya da damla) kırpılır; bu yüzden tam dolu bir arka planı olmalı ve önemli kısım ortadaki güvenli bölgede, yarıçapı icon genişliğinin yüzde 40'ı olan dairenin içinde durmalı. Aynı dosya iki amaca birden verilince ya maskelenen icon'da logonun kenarları kesilir ya da any olarak gösterilen icon gereğinden küçük ve dolgulu görünür. Chrome DevTools'un Manifest bölümü de bu kullanım için uyarı veriyor. Bookstore'da iki ayrı set var: köşeleri yuvarlatılmış icon-192/512.png (any) ve kenara kadar lacivert, kitap figürü güvenli bölgede kalan maskable-192/512.png.

Kurulabilirlik için hangi icon'un gerektiğine de baktım. Chrome'un dokümanı 192 ve 512 piksellik icon istiyor; Chromium'un Page.getInstallabilityErrors komutu ise gerçek kontrolü söylüyor:

Bookstore: appId=http://localhost:5282/ recommendedId=/?source=pwa
  installability errors: none
Bookstore, 192px icons only: appId=http://localhost:5282/ recommendedId=/?source=pwa
  installability errors: none
Bookstore, one 96px icon: appId=http://localhost:5282/ recommendedId=/?source=pwa
  installability errors: manifest-missing-suitable-icon [{"name":"minimum-icon-size-in-pixels","value":"144"}], no-acceptable-icon [{"name":"minimum-icon-size-in-pixels","value":"144"}]

Chromium 158 yalnız 192 piksellik icon'larla da kurulumu kabul etti; hata ancak 144 pikselden küçük tek bir icon kalınca geldi. Yine de iki boyutu da ver: 512 piksellik icon Android'in açılış ekranında ve masaüstündeki büyük icon'larda kullanılıyor. Bu kontroller service worker'ı tamamen engellenmiş bir profilde yapıldı ve kurulabilirlik hatası çıkmadı; Chrome'un dokümanı da artık service worker'ı kurulum koşulu olarak saymıyor. Offline çalışma yine de kurulu bir uygulamadan beklenen şey; bir önceki yazıdaki NGSW yerinde duruyor.

Chrome dokümanındaki diğer koşullar: sayfa HTTPS'ten (ya da localhost'tan) gelmeli, manifest'te name veya short_name, start_url, display (ya da display_override) olmalı ve prefer_related_applications true olmamalı. Bunlara ek olarak Chrome, kullanıcının sayfayla etkileşime girmesini bekleyen bir engagement kontrolü yapıyor.

Kurulum Penceresi: display, screenshots ve shortcuts

display: "standalone" uygulamayı adres çubuğu olmayan kendi penceresinde açar. display_override bir tercih listesi alıyor ve tarayıcı listede desteklediği ilk değeri seçiyor; masaüstü Chromium'da "window-controls-overlay" uygulamanın içeriğini başlık çubuğunun yerine kadar uzatmana izin veriyor. Bookstore buna ihtiyaç duymadığı için yalnız display var. Kurulu pencerede geri butonu olmadığını unutma; Angular kabuğu display-mode media query'siyle yalnız kurulu hâlde bir Back butonu gösteriyor:

  styles: `
    header {
      display: flex;
      gap: 1rem;
      align-items: center;
    }
    nav {
      display: flex;
      gap: 0.75rem;
    }
    .back {
      display: none;
    }
    @media (display-mode: standalone) {
      .back {
        display: inline;
      }
    }
  `,

description ve screenshots kurulumun kendisi için zorunlu değil, ama Chrome bu ikisi varken install dialog'unu uygulama mağazasındakine benzeyen daha zengin bir pencereyle gösteriyor. Chrome'un dokümanına göre bu pencere Android'de Chrome 94'ten, masaüstünde 108'den beri var. form_factor: "wide" masaüstünde, "narrow" mobilde kullanılıyor; Bookstore'un iki ekran görüntüsünü Playwright ile uygulamanın kendisinden aldım. shortcuts ise uygulama icon'una sağ tıklanınca (Android'de uzun basınca) çıkan menü: "My orders" ve "Settings" doğrudan o sayfaları açıyor. BCD'ye göre shortcut'lar Chrome 96 masaüstü, Chrome Android 84 ve Safari 17.4'te (macOS) destekleniyor.

Son olarak index.html'e iki satır ekledim: tarayıcının pencere ve sekme rengini belirleyen theme-color ve iOS'un ana ekran icon'u için apple-touch-icon:

    <link rel="manifest" href="manifest.webmanifest" />
    <meta name="theme-color" content="#1e3a5f" />
    <!-- iOS "Add to Home Screen" için bu icon'u kullanır -->
    <link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />

beforeinstallprompt ile Kendi Install Teklifin

Chromium kurulabilirlik koşulları sağlanınca sayfaya beforeinstallprompt event'ini gönderiyor. Hiçbir şey yapmazsan mobilde kendi küçük install çubuğunu gösteriyor, masaüstünde adres çubuğuna bir install icon'u koyuyor. Event'te preventDefault() çağırıp event'i saklarsan teklifi ne zaman yapacağına sen karar verirsin. Bookstore'da bu karar bir service'te:

import { computed, inject, Service, signal } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs';

// TypeScript 6.0'ın lib.dom.d.ts'inde olmayan, yalnız Chromium'daki API'ler
interface BeforeInstallPromptEvent extends Event {
  readonly platforms: string[];
  readonly userChoice: Promise<{ outcome: 'accepted' | 'dismissed'; platform: string }>;
  prompt(): Promise<void>;
}

declare global {
  interface WindowEventMap {
    beforeinstallprompt: BeforeInstallPromptEvent;
  }
  interface Navigator {
    install?(): Promise<unknown>; // Web Install API
    standalone?: boolean; // yalnız Safari'de (WebKit)
  }
}

const DISMISSED_KEY = 'install-offer-dismissed-at';
const QUIET_DAYS = 30;

@Service()
export class InstallPrompt {
  private deferred: BeforeInstallPromptEvent | null = null;
  private readonly promptable = signal(false);
  private readonly pagesSeen = signal(0);
  private readonly dismissedAt = signal(Number(localStorage.getItem(DISMISSED_KEY) ?? 0));
  private readonly standalone = matchMedia('(display-mode: standalone)');

  readonly installed = signal(this.standalone.matches || navigator.standalone === true);
  // Safari beforeinstallprompt göndermez; kullanıcı Share ya da File menüsünden kurar
  readonly safariHint = 'standalone' in navigator && !this.installed();
  // kurulumu ancak kullanıcı biraz gezindikten sonra öner; "Not now"dan sonra 30 gün tekrar önerme
  readonly offer = computed(
    () =>
      this.promptable() &&
      !this.installed() &&
      this.pagesSeen() >= 3 &&
      Date.now() - this.dismissedAt() > QUIET_DAYS * 24 * 60 * 60 * 1000,
  );

  constructor() {
    addEventListener('beforeinstallprompt', (event) => {
      event.preventDefault(); // mini-infobar yok: ne zaman önereceğimize biz karar veriyoruz
      this.deferred = event;
      this.promptable.set(true);
    });
    addEventListener('appinstalled', () => {
      this.deferred = null;
      this.promptable.set(false);
      this.installed.set(true);
    });
    this.standalone.addEventListener('change', (e) => this.installed.set(e.matches));
    inject(Router)
      .events.pipe(filter((e) => e instanceof NavigationEnd))
      .subscribe(() => this.pagesSeen.update((n) => n + 1));
  }

  async install(): Promise<string> {
    const event = this.deferred;
    if (event) {
      // prompt() her event için bir kez çalışır; Chromium sonra yeni bir event gönderir
      this.deferred = null;
      this.promptable.set(false);
      await event.prompt();
      const { outcome } = await event.userChoice;
      if (outcome === 'dismissed') this.notNow();
      return outcome;
    }
    if (navigator.install) {
      try {
        await navigator.install();
        return 'accepted';
      } catch (err) {
        return (err as DOMException).name; // AbortError: kullanıcı vazgeçti, DataError: manifest'te id yok
      }
    }
    return 'unavailable';
  }

  notNow() {
    const now = Date.now();
    localStorage.setItem(DISMISSED_KEY, String(now));
    this.dismissedAt.set(now);
  }
}

Kural basit: kullanıcı en az üç sayfa görmeden teklif yok; "Not now" derse 30 gün boyunca yok. Uygulamanın ilk saniyesinde açılan bir install penceresi kullanıcının henüz neyi kuracağını bilmediği bir anda gelir ve çoğunlukla kapatılır. BeforeInstallPromptEvent ve navigator.install TypeScript 6.0'ın lib.dom.d.ts'inde olmadığı için tipleri dosyanın başında tanımladık. Kabuk teklifi header'da iki küçük butonla gösteriyor (app.ts'teki @if (installer.offer()) bloğu). Playwright'ın kalıcı profilli Chromium'unda akış şöyle:

[chromium] beforeinstallprompt fired
[chromium] Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner.
[chromium] page 1 (catalog): offer visible = false
[chromium] page 2 (Dune): offer visible = false
[chromium] page 3 (Settings): offer visible = true
[chromium] 10 s after the click: no answer, the install dialog is open, offer visible = false
[chromium] install: dismissed
[chromium] beforeinstallprompt fired
[chromium] Banner not shown: beforeinstallpromptevent.preventDefault() called. The page must call beforeinstallpromptevent.prompt() to show the banner.
[chromium] after opening another tab: install-offer-dismissed-at saved: true

Birkaç şey dikkat çekiyor. Event ilk sayfada geldi ama teklif üçüncü sayfaya kadar görünmedi. preventDefault() çağrısı konsola Chromium'un kendi notunu düşürüyor; bu bir hata değil. Install butonuna basınca Chromium'un install dialog'u açıldı; headless tarayıcıda onu yanıtlayan kimse olmadığı için userChoice 10 saniye boyunca beklemede kaldı, başka bir sekme açılınca dialog kapandı ve sonuç dismissed oldu. Service bunu "Not now" gibi kaydetti. Bir de event'in tekrar geldiğini gördük: prompt() her event için bir kez çalışıyor, kullanıcı reddedince Chromium yeni bir beforeinstallprompt gönderiyor. Service yeni event'i saklıyor ama 30 günlük sessizlik bitene kadar teklif göstermiyor.

Testle ilgili küçük bir not: bu akış Playwright'ın varsayılan headless shell'inde çalışmadı ve tam Chromium'da da incognito benzeri yeni bir context'te Page.getInstallabilityErrors in-incognito döndü. Install akışını test etmek için tam Chromium ve kalıcı bir profil (launchPersistentContext) gerekiyor.

beforeinstallprompt hiçbir zaman bir standart olmadı; BCD onu standards track dışı olarak işaretliyor ve yalnız Chromium tabanlı tarayıcılarda var. Microsoft Edge ekibinin önerdiği Web Install API aynı işi bir method çağrısına indiriyor: kullanıcı bir butona bastığında navigator.install() çağırırsın, tarayıcı install dialog'unu gösterir. Argümansız çağrı mevcut sayfanın uygulamasını kurar; { manifest, manifestId } argümanıyla başka bir origin'in uygulamasını kurmak da mümkün, bu da uygulama kataloğu gibi senaryoları açıyor. Destek durumu hareketli: BCD 8.1.9'da Chrome ve Edge 154 yalnız flag arkasında, Chromium'un Eylül 2026 tarihli Intent to Ship başvurusu masaüstü için 156'yı hedefliyor ve Playwright'ın Chromium 158 build'inde navigator.install flag'siz olarak tanımlı. Firefox ve Safari'de yok. Üç durumu denedim:

ng add manifest (no id), click: install() rejected DataError: The data provided does not meet requirements.
Bookstore manifest, no user activation: install() rejected NotAllowedError: Failed to execute 'install' on 'Navigator': Unable to install app. This API can only be called shortly after a user activation.
Bookstore manifest, click: still pending after 10 s (the install dialog waits for the user)

Argümansız çağrı manifest'te id istiyor; ng add manifest'i bu yüzden DataError aldı. Kullanıcı etkileşimi olmadan yapılan çağrı NotAllowedError ile reddedildi; bir sayfa açılışında kendiliğinden install penceresi açmak mümkün değil. id'li manifest'te butona basınca dialog açıldı ve promise kullanıcının cevabını bekledi; kullanıcı vazgeçerse AbortError geliyor. InstallPrompt.install() bu yüzden önce saklanan beforeinstallprompt event'ini kullanıyor (sonucu userChoice ile öğrenebiliyoruz), event yoksa ve tarayıcıda navigator.install varsa ona düşüyor. Test yazarken bir tuzağa da dikkat: Playwright'ın page.evaluate'i Chromium'da kodu user gesture ile çalıştırıyor, yani evaluate içinden yapılan çağrı "kullanıcı tıkladı" sayılıyor. NotAllowedError'ı görmek için çağrıyı etkileşimden saniyeler sonra bir timer'dan yapmam gerekti.

Safari ve iOS: Ana Ekrana Ekle

Safari'de install API'si yok: ne beforeinstallprompt ne navigator.install. Kullanıcı uygulamayı iPhone ve iPad'de Share menüsündeki "Add to Home Screen" ile, Mac'te (Safari 17 ve macOS Sonoma'dan beri) File menüsündeki "Add to Dock" ile kendisi kuruyor. Yapabileceğin tek şey bunu doğru yerde anlatmak. MDN'e göre iOS 16.4'ten beri Chrome, Edge ve Firefox gibi diğer iOS tarayıcıları da Share menüsünden ana ekrana ekleyebiliyor. WebKit'in Safari 26 duyurusuna göre de iOS 26 ve iPadOS 26'da ana ekrana eklenen her site, manifest'i olmasa bile varsayılan olarak web app olarak açılıyor; kullanıcı "Open as Web App" seçeneğini kapatabiliyor.

Bookstore'un Settings sayfası Safari kullanıcısına talimatı gösteriyor. Safari'yi tanımak için navigator.standalone'un varlığına baktık; bu property yalnız WebKit'te var ve kurulu bir iOS web app'inde true oluyor. Önce talimatı yalnız iPhone için yazmıştım, Playwright'ın WebKit'i bu property'nin masaüstü WebKit'te de tanımlı olduğunu gösterdi; metni Mac'i de kapsayacak şekilde düzelttim:

[webkit] setAppBadge: undefined, beforeinstallprompt: false, navigator.standalone: true
[webkit] nav "Catalog Order report Covers My orders (2) Settings", install text: "To install Bookstore in Safari, choose Share, then Add to Home Screen (iPhone, iPad) or File, then Add to Dock (Mac)."

iOS'ta icon için apple-touch-icon'u (180×180, saydamlıksız) ekle. Safari manifest icon'larını 15.4'ten beri okuyor, ama iki kaynak birlikte olduğunda hangisinin seçildiği sürümler arasında değişmiş; apple-touch-icon her sürümde çalışan yol.

App Badging ile Okunmamış Siparişler

Kurulu bir uygulamanın icon'unda sayı göstermek Badging API'siyle iki method'luk iş: navigator.setAppBadge(n) ve navigator.clearAppBadge(). Bookstore'da sayı, müşterinin henüz görmediği sipariş güncellemeleri. Sahte API'ye iki endpoint ekledim:

  } else if (req.method === "GET" && url.pathname === "/api/me/order-updates") {
    sendJson(req, res, no, { unread: unreadOrderUpdates });
  } else if (req.method === "POST" && url.pathname === "/api/me/order-updates/read") {
    unreadOrderUpdates = 0;
    sendJson(req, res, no, { unread: 0 });
  } else if (req.method === "GET" && url.pathname === "/api/reports/orders.csv") {

Angular tarafında sayıyı hem header'daki "My orders" linkine hem uygulama icon'una yansıtan küçük bir service var:

import { HttpClient } from '@angular/common/http';
import { inject, Service, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';

interface OrderUpdates {
  unread: number;
}

@Service()
export class OrderBadge {
  private readonly http = inject(HttpClient);
  readonly unread = signal(0);

  async refresh() {
    try {
      const { unread } = await firstValueFrom(this.http.get<OrderUpdates>('/api/me/order-updates'));
      await this.show(unread);
    } catch {
      // offline: son sayıyı koru
    }
  }

  async markRead() {
    await firstValueFrom(this.http.post<OrderUpdates>('/api/me/order-updates/read', null));
    await this.show(0);
  }

  private async show(count: number) {
    this.unread.set(count);
    if (!('setAppBadge' in navigator)) return; // Firefox ve kurulu bir web app dışındaki Safari
    try {
      if (count > 0) await navigator.setAppBadge(count);
      else await navigator.clearAppBadge();
    } catch (err) {
      console.log(`badge: ${(err as DOMException).name}`);
    }
  }
}

Kabuk açılışta refresh() çağırıyor, "My orders" sayfası açılınca markRead(). Badge'in gerçekten icon'a gidip gitmediğini headless bir tarayıcıda göremezsin, ama Chromium'un DevTools protokolü kurulu uygulamanın işletim sistemi tarafındaki durumunu PWA.getOsAppState ile veriyor. Uygulamayı PWA.install ile kurup sayfayı yeniledim:

[chromium] installed (PWA.install over CDP), badge 0
[chromium] after reload: nav "Catalog Order report Covers My orders (2) Settings", badge 2
[chromium] orders page opened: nav "Catalog Order report Covers My orders Settings", badge 0

Kurulumdan önce yapılan setAppBadge çağrıları hata vermeden sonuçlanıyor ama bir işe yaramıyor; kurulumdan sonra aynı çağrı icon'a 2 yazdı, sipariş sayfası açılınca 0'a döndü. Ayrıca denediğimde aynı scope'taki sıradan bir tarayıcı sekmesinden yapılan çağrı da kurulu uygulamanın badge'ini değiştirdi. Destek: BCD'ye göre Chrome ve Edge 81'den beri Windows ve macOS'ta (ChromeOS 91'den beri; Linux'ta işletim sistemi desteği yok), Safari 17'de kurulu Mac uygulamalarında, iOS 16.4'ten beri ana ekrana eklenmiş web app'lerde. Chrome Android ve Firefox'ta setAppBadge yok; WebKit 27.2'de de normal bir sekmede tanımlı değildi, service'teki feature detection bu yüzden. WebKit'in iOS 16.4 duyurusuna göre iOS'ta badge, kullanıcı bildirim izni verdiğinde görünüyor; izin konusunu bir sonraki yazıda Web Push ile birlikte ele alıyoruz.

Üretimde Dikkat

Kurulum sayısını ölç: appinstalled event'i ve start_url'deki ?source=pwa parametresi, kurulu uygulamadan gelen oturumları ayırmanı sağlıyor. display-mode: standalone'u da analytics'e bir boyut olarak gönderebilirsin. Teklifin kabul oranını (userChoice sonuçları) izlemek, teklifi ne zaman göstereceğine veriyle karar vermeni sağlar.

Manifest'i bir sözleşme gibi yönet. id bir kez yazılır ve hiç değişmez; scope'u daraltmak kurulu uygulamanın bazı sayfalarını pencere dışına atar. Manifest'in kendisi no-cache ile sunulmalı (bir önceki yazıdaki header kuralları zaten öyle). Kurulu uygulamanın manifest'i, kullanıcı uygulamayı açtığında ya da siteye girdiğinde yeniden okunuyor ve değişiklikler o zaman uygulanıyor. Chrome'un dokümanına göre name ve icon güvenlik açısından hassas sayıldığı için ayrı ele alınıyor ve kullanıcının onayına bırakılabiliyor; yalnız küçük icon farkları kendiliğinden uygulanıyor. Ad ve icon değişikliğinin hemen yansımasını bekleme.

Ne zaman PWA yetmez? Bluetooth, NFC (yakın alan iletişimi) ya da arka planda konum gibi native API'lere derin ihtiyaç varsa, uygulama mağazasında bulunmak bir iş gereği ise ya da iOS'ta arka planda düzenli çalışan bir iş gerekiyorsa PWA zayıf kalır. Web Bluetooth ve Web NFC yalnız Chromium'da (NFC yalnız Android'de) var; Safari'de periyodik arka plan işi yok. Bookstore gibi katalog, sepet ve sipariş takibi yapan bir uygulama için PWA yeterli; mağaza zorunluysa Android'de Trusted Web Activity ile aynı PWA'yı paketlemek bir ara yol.

Sık Düşülen Tuzaklar

  • Manifest'e id yazmadan yayına çıkmak. Sonradan start_url değişirse kimlik de değişir; eski kurulumlar başka bir uygulama gibi kalır.
  • Aynı icon'a "purpose": "maskable any" vermek. Maskelenen icon kırpılır ya da any icon küçük görünür; iki ayrı set üret.
  • Install teklifini sayfa açılır açılmaz göstermek ya da reddedildikten sonra her oturumda tekrarlamak. Kullanıcı bir değer gördükten sonra sor ve "Not now"a saygı göster.
  • beforeinstallprompt'u her tarayıcıda bekleyen bir UI yazmak. Safari ve Firefox'ta event hiç gelmez; feature detection ve Safari için talimat gerekir.
  • navigator.install()'u kullanıcı etkileşimi olmadan çağırmak. NotAllowedError alırsın; çağrı bir click handler'ından gelmeli.
  • Badge'i sayfa açıkken bir kez yazıp bırakmak. Kullanıcı ilgili sayfayı görünce clearAppBadge() çağır; yoksa icon'daki sayı gerçeği yansıtmaz.

Küçük Bir Deneme

★☆☆ Manifest'e üçüncü bir shortcut ekle: "Order report" (/reports/orders). Chromium'da Page.getAppManifest ile parse edilen manifest'te shortcut'ların sırasını ve URL'lerini yazdır.

★★☆ InstallPrompt'a teklifin kaç kez gösterildiğini ve sonuçlarını (accepted, dismissed) sahte API'deki /api/events endpoint'ine sendBeacon ile gönderen bir ölçüm ekle. Bu serinin ilk yazısındaki event endpoint'inin log'unda gördüğün satırları kontrol et.

★★★ Bookstore'u /store/ alt path'inde yayınlanacak şekilde derle (--base-href /store/). Manifest'te id, scope ve start_url'i doğru ayarla; Page.getAppId ile kimliğin kökteki kurulumdan farklı olduğunu doğrula ve iki kurulumun neden ayrı uygulamalar olduğunu açıkla.

Uygulama icon'undaki sayı şimdilik yalnız uygulama açıkken güncelleniyor. Sipariş kargoya verildiğinde, uygulama kapalıyken bile kullanıcıya haber vermek Web Push'un işi. Bir sonraki yazıda VAPID (Voluntary Application Server Identification; sunucunun push servisine kendini tanıtması) anahtarları, Angular'ın SwPush'u ve ASP.NET Core'dan bildirim göndermek var.