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

Cihazla Etkileşen Tarayıcı API'leri: Async Clipboard, Web Share, Screen Wake Lock, Fullscreen, Permissions ve Geolocation

Mert Özen 15 Kas 2026 22 dk 9 görüntülenme
Cihazla Etkileşen Tarayıcı API'leri: Async Clipboard, Web Share, Screen Wake Lock, Fullscreen, Permissions ve Geolocation

Pano, paylaşım, tam ekran ve konum API'lerini bir tıklamanın içinden çağırır, izin durumunu Permissions API ile önceden okur ve her birine feature detection ile bir fallback koyarsın; wake lock dışındakiler user activation olmadan reddediliyor. Bookstore'da hepsini tek bir DeviceCapabilities service'inde topluyor, Chromium 158 ve WebKit 27.2'de aktivasyonun tükenmesini, ClipboardItem'a promise vermenin farkını ve konumu sunucuya göndermeden en yakın depoyu bulmayı gösteriyoruz.

Sürüm notu: Bu yazıdaki örnekler Angular 22.2 (TypeScript 6.0), 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. Kullanıcı kararı gerektiren izinler Playwright context izinleriyle verilmiştir; konum, Playwright'ın verdiği sabit ve uydurma bir konumdur. 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/Android cihazlar 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 istemcisine bir e-kitap önizleme sayfası ekliyoruz. Ekipten biri okuma deneyimini "tam" yapmak için component'in ngOnInit'ine üç satır yazıyor: okuyucuyu tam ekrana al (requestFullscreen), ekran kararmasın (navigator.wakeLock.request('screen')) ve kitabın linkini panoya kopyala (navigator.clipboard.writeText). Kullanıcı önizleme linkini bir e-postadan açıyor, yani sayfaya hiç tıklamadan geliyor. Bu üç çağrıdan hangileri çalışır?

Bu ek yazı, hemen her uygulamada istenen küçük cihaz API'lerine ayrıldı: async Clipboard, Web Share, Screen Wake Lock, Fullscreen, Permissions ve Geolocation. Bookstore'da hepsi tek bir DeviceCapabilities service'inin arkasında duruyor.

Ortak Kurallar: Secure Context, User Activation ve İzin Durumu

Bu API'lerin üç ortak kuralı var. Birincisi secure context: hepsi yalnız HTTPS'te ya da localhost/127.0.0.1'de çalışıyor (isSecureContext). İkincisi user activation (kullanıcının sayfayla gerçek bir etkileşimi; tıklama, tuşa basma): bazı API'ler yalnız böyle bir etkileşimden hemen sonra çağrılabiliyor. Üçüncüsü izin: bazıları kullanıcıya soruyor, bazıları sormadan veriyor. Hangisinin aktivasyon istediğini ezberden yazmak yerine framework'süz bir lab sayfasında (web/device/) aynı çağrıları bir kez tıklamadan, bir kez de sayfa açıldıktan 7 saniye sonra bir timer'dan yaptım:

// Hangi cihaz API'leri user activation ister? Aynı çağrılar bir tıklamadan ve bir timer'dan.
const calls = {
  writeText: () => navigator.clipboard.writeText("1051"),
  fullscreen: () => document.documentElement.requestFullscreen(),
  wakeLock: () => navigator.wakeLock.request("screen"),
  // aktivasyonu tüketen iki API, aynı tıklamada art arda
  fullscreenThenShare: async () => {
    await document.documentElement.requestFullscreen();
    await navigator.share({ title: "Dune", url: location.href });
  },
  share: () => navigator.share({ title: "Dune", url: location.href }),
};

async function run(source, name) {
  const active = navigator.userActivation.isActive;
  // paylaşım penceresi uzun süre açık kalabilir: onu bekleme
  const result = await Promise.race([
    calls[name]().then(() => "ok", (err) => `${err.name}: ${err.message}`),
    new Promise((resolve) => setTimeout(() => resolve("still pending"), 2000)),
  ]);
  console.log(`${source.padEnd(5)} ${name.padEnd(19)} activation=${String(active).padEnd(5)} -> ${result}`);
  if (document.fullscreenElement) await document.exitFullscreen();
}

for (const name of Object.keys(calls)) {
  const button = document.createElement("button");
  button.id = name;
  button.textContent = name;
  button.addEventListener("click", () => run("click", name));
  document.getElementById("buttons").append(button, " ");
}

// her türlü user activation'ın süresi dolduktan çok sonra
setTimeout(async () => {
  for (const name of Object.keys(calls)) await run("timer", name);
  console.log("timer done");
}, 7000);
[chromium] timer writeText           activation=false -> NotAllowedError: Failed to execute 'writeText' on 'Clipboard': Write permission denied.
[chromium] timer fullscreen          activation=false -> TypeError: Permissions check failed
[chromium] timer wakeLock            activation=false -> ok
[chromium] timer fullscreenThenShare activation=false -> TypeError: Permissions check failed
[chromium] timer share               activation=false -> NotAllowedError: Failed to execute 'share' on 'Navigator': Must be handling a user gesture to perform a share request.
[chromium] click writeText           activation=true  -> ok
[chromium] click fullscreen          activation=true  -> ok
[chromium] click wakeLock            activation=true  -> ok
[chromium] click fullscreenThenShare activation=true  -> NotAllowedError: Failed to execute 'share' on 'Navigator': Must be handling a user gesture to perform a share request.
[chromium] click share               activation=true  -> still pending

Tahminin cevabı timer satırlarında: tıklamasız çağrılardan yalnız wake lock çalıştı. requestFullscreen TypeError, writeText ve share NotAllowedError verdi. WebKit 27.2'de sonuç aynıydı, yalnız hata mesajları farklıydı. Bu denemede Playwright context'inde clipboard-write ve screen-wake-lock izinlerini verdim (aşağıda göreceğimiz gibi taze bir Chromium profilinde de ikisi zaten granted); yani Chromium 158'de izin verilmiş olması, aktivasyon olmadan panoya yazmaya yetmedi.

İkinci önemli satır fullscreenThenShare: aynı tıklamada önce tam ekran, sonra paylaşım istendi ve share "Must be handling a user gesture" hatasıyla reddedildi. Bazı API'ler aktivasyonu tüketiyor: tam ekran ve paylaşım tıklamanın verdiği hakkı kullanıp bitiriyor. WebKit'te de aynı sırayla aynı sonuç çıktı. Kural basit: bir tıklama, aktivasyonu tüketen tek bir işlem. Chromium'da tek başına share çağrısı da 2 saniye içinde sonuçlanmadı: headless tarayıcıda paylaşıma cevap verecek bir kullanıcı yok ve promise beklemede kalıyor. Playwright'ın WebKit'i ise share'i hemen başarılı sayıyor; gerçek bir paylaşım penceresini iki motorda da göremedim.

İzin durumunu önceden okumak için Permissions API'si (navigator.permissions.query) var. Üç farklı başlangıç durumunda okudum:

chromium, fresh profile:     geolocation=prompt screen-wake-lock=granted clipboard-write=granted clipboard-read=prompt notifications=prompt
chromium, Playwright context: geolocation=prompt screen-wake-lock=granted clipboard-write=granted clipboard-read=prompt notifications=prompt
webkit, Playwright context:   geolocation=prompt screen-wake-lock=prompt clipboard-write=TypeError clipboard-read=TypeError notifications=prompt

Taze bir Chromium profilinde konum ve pano okuma prompt (kullanıcıya sorulacak), pano yazma ve wake lock granted. WebKit'te wake lock prompt, clipboard-write ve clipboard-read ise TypeError veriyor: BCD'ye göre bu iki ad yalnız Chromium'da var, Safari ve Firefox tanımıyor. TypeScript 6.0'ın lib.dom.d.ts'indeki PermissionName tipi de bu iki adı içermiyor. Bu yüzden izin okuma kodu her zaman bir hata yolu ile yazılmalı. Bir uyarı daha: Playwright'ın Chromium'unda context'e bir permissions listesi verince listede olmayan izinler denied oluyor (WebKit'te prompt kaldı); testlerde gördüğün durumun gerçek bir profildeki durumla aynı olduğunu varsayma.

Bookstore'un DeviceCapabilities Service'i

Cihaz API'lerini component'lere dağıtmak yerine tek bir service'te topladım. Component yalnız "bu tarayıcı bunu yapabilir mi?" diye soruyor ve butonu ona göre gösteriyor:

import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { inject, PLATFORM_ID, Service, Signal, signal } from '@angular/core';

export type ShareResult = 'shared' | 'copied' | 'cancelled' | 'failed';
export type PermissionView = PermissionState | 'unknown';

// Bookstore'un kullandığı her cihaz API'si bu service'ten geçer: feature detection, user activation kuralları ve
// fallback'ler tek yerde durur, component'ler yalnız "yapabilir miyim?" ve "yap" der.
@Service()
export class DeviceCapabilities {
  private readonly document = inject(DOCUMENT);
  // server-side rendering sırasında navigator da window da yok
  private readonly browser = isPlatformBrowser(inject(PLATFORM_ID));

  readonly supports = {
    share: this.browser && 'share' in navigator,
    clipboard: this.browser && navigator.clipboard?.writeText !== undefined,
    clipboardImage:
      this.browser &&
      typeof ClipboardItem !== 'undefined' &&
      'supports' in ClipboardItem &&
      ClipboardItem.supports('image/png'),
    wakeLock: this.browser && 'wakeLock' in navigator,
    fullscreen: this.browser && this.document.fullscreenEnabled === true,
    geolocation: this.browser && 'geolocation' in navigator,
  };

  private sentinel: WakeLockSentinel | null = null;
  private wakeLockWanted = false;
  readonly screenOn = signal(false);
  readonly fullscreen = signal(false);

  constructor() {
    if (!this.browser) return;
    // sayfa gizlenince tarayıcı wake lock'u bırakır; kullanıcı dönünce yeniden al
    this.document.addEventListener('visibilitychange', () => {
      if (this.wakeLockWanted && this.document.visibilityState === 'visible')
        void this.acquireWakeLock();
    });
    this.document.addEventListener('fullscreenchange', () =>
      this.fullscreen.set(this.document.fullscreenElement !== null),
    );
  }

  // Bunları bir click handler'dan çağır: share, panoya yazma ve fullscreen user activation ister.

supports bir kez, service oluşurken hesaplanıyor. Kontrollerin 'share' in navigator şeklinde olmasının bir sebebi var: TypeScript 6.0'ın tiplerinde navigator.share her zaman tanımlı göründüğü için alışılmış kontrol derlenmiyor:

export const canShare = navigator.share ? true : false;
export const canShareToo = 'share' in navigator;
$ tsc --noEmit --strict --lib es2022,dom --target es2022 a.ts
a.ts(1,25): error TS2774: This condition will always return true since this function is always defined. Did you mean to call it instead?

İkinci satır hem derleniyor hem de çalışma zamanında doğru cevabı veriyor. isPlatformBrowser kontrolü SSR (Server-Side Rendering; sayfanın sunucuda HTML olarak üretilmesi) için; Bookstore'un istemcisi SSR'sız, ama service'in bir SSR projesine taşındığında kırılmaması gerekiyor (Üretimde Dikkat bölümünde Node.js'in burada nasıl davrandığına bakıyoruz).

Async Clipboard: Metin, Görsel ve paste Event'i

JavaScript Temel serisinin "JavaScript BOM: window, location, navigator ve Timer'lar" yazısında navigator.clipboard'a tek satırla değinmiştik. Service'teki pano ve paylaşım method'ları:

  async share(data: ShareData): Promise<ShareResult> {
    if (this.supports.share && navigator.canShare(data)) {
      try {
        await navigator.share(data);
        return 'shared';
      } catch (err) {
        // AbortError: kullanıcı paylaşım penceresini kapattı; bu bir hata değil, cevap
        return (err as DOMException).name === 'AbortError' ? 'cancelled' : 'failed';
      }
    }
    // paylaşım penceresi yok (ör. masaüstünde Firefox) ya da veri paylaşılamıyor: linki kopyala
    return (await this.copyText(data.url ?? data.text ?? '')) ? 'copied' : 'failed';
  }

  async copyText(text: string): Promise<boolean> {
    if (!this.supports.clipboard) return false;
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (err) {
      console.log(`clipboard: ${(err as DOMException).name}`);
      return false;
    }
  }

  async copyImage(png: Promise<Blob>): Promise<boolean> {
    if (!this.supports.clipboardImage) return false;
    try {
      // blob'u değil promise'i ver: write() tıklamanın içinde başlar, görsel sonra gelir
      await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
      return true;
    } catch (err) {
      console.log(`clipboard: ${(err as DOMException).name}`);
      return false;
    }
  }

"My orders" sayfasında her siparişin yanında bir "Copy number" butonu var; müşteri numarayı destek ekibine yazdığı e-postaya yapıştırıyor:

    @if (orders.hasValue()) {
      <ul class="orders">
        @for (order of orders.value().orders; track order) {
          <li>
            Order {{ order }}
            @if (device.supports.clipboard) {
              <button (click)="copy(order)">
                {{ copied() === order ? 'Copied' : 'Copy number' }}
              </button>
            }
          </li>
        }
      </ul>

Kitap sayfasındaki "Copy as image" butonu ise kitabın adını, yazarını ve fiyatını bir canvas'a çizip PNG (kayıpsız görsel formatı) olarak panoya koyuyor, böylece sohbet uygulamalarına görsel olarak yapıştırılabiliyor:

// Sohbet uygulamaları için küçük bir PNG "kart": canvas'a çizilmiş ad, yazar ve fiyat.
function bookCard(book: Book): Promise<Blob> {
  const canvas = document.createElement('canvas');
  canvas.width = 600;
  canvas.height = 300;
  const g = canvas.getContext('2d')!;
  g.fillStyle = '#1e3a5f';
  g.fillRect(0, 0, 600, 300);
  g.fillStyle = '#ffffff';
  g.font = 'bold 44px sans-serif';
  g.fillText(book.title, 32, 110);
  g.font = '28px sans-serif';
  g.fillText(book.author, 32, 160);
  g.fillText(`${book.price} TRY at Bookstore`, 32, 250);
  return new Promise((resolve, reject) =>
    canvas.toBlob(
      (blob) => (blob ? resolve(blob) : reject(new Error('toBlob failed'))),
      'image/png',
    ),
  );
}

ClipboardItem'a Blob'un kendisini değil, bir Promise<Blob> veriyoruz. Nedenini ayrı bir denemede ölçtüm: tıklamanın içinde önce bekleyip görseli sonra hazırlamak ile write()'ı hemen çağırıp görseli promise olarak vermek arasındaki fark:

[chromium] blob after 1.5 s -> ok
[chromium] promise, 1.5 s   -> ok
[chromium] blob after 6 s   -> NotAllowedError
[chromium] promise, 6 s     -> ok
[webkit] blob after 1.5 s -> ok
[webkit] promise, 1.5 s   -> ok
[webkit] blob after 6 s   -> NotAllowedError
[webkit] promise, 6 s     -> ok

1,5 saniyelik bekleme iki motorda da sorun olmadı, ama görsel 6 saniye sonra hazır olunca write() çağrısı aktivasyonun süresi dolduğu için reddedildi. Promise verince write() tıklamanın içinde başladığı için görsel 6 saniye sonra gelse de kopyalama başarılı oldu. Görseli sunucudan indiriyorsan ya da büyük bir canvas'ı sıkıştırıyorsan bu fark gerçek. WebKit'in Async Clipboard duyurusundaki örnekler de ClipboardItem'a değer olarak promise veriyor. Format desteğini ClipboardItem.supports() ile sorabilirsin: iki motorda da image/png desteklendi, image/jpeg desteklenmedi. Uygulamayla Chromium'da yapılan denemenin çıktısı:

--- 1. book page: Share and Copy as image
[chromium] buttons: Add to reading list | Share | Copy as image | Send review
[chromium] copy card: true
[chromium] clipboard now: image/png, 15757 bytes, 600x300
[chromium] 3 s after clicking Share: no answer yet, the share sheet is open
--- 2. a browser without navigator.share (simulated: share and canShare removed before the app starts)
[chromium] share: copied
[chromium] message: "Link copied.", clipboard: http://127.0.0.1:52411/books/9780441172719
--- 3. My orders: Copy number
[chromium] order row: "Order 1051 Copied", clipboard: 1051

Panodan geri okunan görsel image/png, 600x300 ve yaklaşık 15 KB. Panodan okumak (readText, read) daha hassas: Chromium clipboard-read izni istiyor; WebKit'in 2020 duyurusuna göre Safari, içeriği başka bir origin yazdıysa bir "Yapıştır" menüsü gösteriyor, BCD'ye göre Firefox da benzer bir onay istiyor. Testte geri okuyabilmek için Chromium context'ine clipboard-read verdim; uygulama panoyu hiç okumuyor.

Kullanıcının yapıştırdığı veriye ulaşmak için okuma iznine gerek yok: paste event'i kullanıcı Ctrl+V'ye ya da menüdeki Yapıştır'a bastığında geliyor ve veri event'in içinde. Kurumsal teslimat adresi formunda e-postadan kopyalanan, boşlukları ve boş satırları karışık adresi düzeltiyoruz:

  // paste event'i izin istemez: yapıştırmayı kullanıcı seçti. E-postadan kopyalanan adresleri düzelt.
  paste(event: ClipboardEvent) {
    const text = event.clipboardData?.getData('text/plain');
    if (!text) return;
    event.preventDefault();
    const tidy = text
      .split(/\r?\n/)
      .map((line) => line.replace(/\s+/g, ' ').trim())
      .filter((line) => line !== '')
      .join('\n');
    this.address.set(tidy);
    console.log(`paste: ${JSON.stringify(text)} -> ${JSON.stringify(tidy)}`);
  }
--- 7. paste an address copied from an e-mail
[chromium] paste: "  Example Primary School  \n\n   Main Street   12\r\n 42000   Konya \n" -> "Example Primary School\nMain Street 12\n42000 Konya"
[chromium] textarea: "Example Primary School\nMain Street 12\n42000 Konya"

Chromium'da metni panoya koyup Playwright'ın klavyesiyle yapıştırma kısayoluna bastım. WebKit'te Playwright pano okuma iznini desteklemediği için aynı metinle bir paste event'i oluşturup gönderdim; sonuç aynıydı.

Web Share ve "Linki Kopyala" Fallback'i

navigator.share işletim sisteminin paylaşım penceresini açıyor: mesajlaşma uygulamaları, e-posta, AirDrop. share() metodunun canShare() adında bir kardeşi var; özellikle dosya paylaşırken verinin paylaşılabilir olup olmadığını önceden söylüyor. Kitap sayfasındaki buton:

      <a [routerLink]="['/books', isbn(), 'preview']">Read a sample</a>
      <!-- ne paylaşım penceresi ne pano var: hiç buton yok -->
      @if (device.supports.share || device.supports.clipboard) {
        <button class="share" (click)="share(book.value())">
          {{ device.supports.share ? 'Share' : 'Copy link' }}
        </button>
      }
      @if (device.supports.clipboardImage) {
        <button class="copy-card" (click)="copyCard(book.value())">Copy as image</button>
      }
      @if (shareMessage()) {
        <p class="share-message">{{ shareMessage() }}</p>
      }
  async share(book: Book) {
    const url = `${location.origin}/books/${book.isbn}`;
    const result = await this.device.share({
      title: book.title,
      text: `${book.title} by ${book.author}`,
      url,
    });
    console.log(`share: ${result}`);
    const messages = {
      shared: '',
      cancelled: '',
      copied: 'Link copied.',
      failed: 'Copy this link: ' + url,
    };
    this.shareMessage.set(messages[result]);
  }

Sonuçlara bakarken iki şeyi ayırmak gerekiyor. AbortError, kullanıcının paylaşım penceresini kapattığı (ya da hiç hedef olmadığı) anlamına geliyor; bu bir hata değil, kullanıcının cevabı, ekrana hata mesajı basma. share hiç yoksa buton "Copy link" yazıyor ve linki panoya koyuyor; ikisi de yoksa buton hiç görünmüyor. Yukarıdaki çıktının 2. adımı bu yolu gösteriyor: Firefox masaüstünü taklit etmek için navigator.share ve canShare'i uygulama başlamadan silen bir init script ile sayfayı açtım; buton "Copy link" oldu ve link panoya gitti. Bu bir simülasyon, gerçek Firefox değil.

BCD'ye göre Chrome masaüstünde Web Share 128'e kadar yalnız Windows ve ChromeOS'ta vardı. Kurulu bir PWA'nın (Progressive Web App; tarayıcıdan kurulabilen web uygulaması) başka uygulamaların paylaşım penceresinde görünmesi ise manifest'teki share_target ile oluyor ve web-features'a göre yalnız Chromium'da var; manifest'i Kurulabilir PWA yazısında kurmuştuk.

Screen Wake Lock ve Fullscreen: Önizleme Ekranı

E-kitap önizlemesi iki API kullanıyor: Screen Wake Lock ekran kararmasın diye, Fullscreen API okuyucuyu tam ekrana almak için. Service'teki kısım:

  async toggleFullscreen(element: Element): Promise<void> {
    if (this.document.fullscreenElement) await this.document.exitFullscreen();
    else await element.requestFullscreen({ navigationUI: 'hide' });
  }

  // `on` true olduğu sürece ekranı açık tutar. User activation gerekmez, ama kilit yalnız sayfa görünürken yaşar.
  async keepScreenOn(on: boolean): Promise<void> {
    this.wakeLockWanted = on;
    if (on) await this.acquireWakeLock();
    else await this.sentinel?.release();
  }

  private async acquireWakeLock() {
    if (!this.supports.wakeLock || this.sentinel || this.document.visibilityState !== 'visible')
      return;
    try {
      const sentinel = await navigator.wakeLock.request('screen');
      sentinel.addEventListener('release', () => {
        this.sentinel = null;
        this.screenOn.set(false);
        console.log('wake lock: released');
      });
      this.sentinel = sentinel;
      this.screenOn.set(true);
      console.log('wake lock: acquired');
    } catch (err) {
      // NotAllowedError: bir policy, izin ya da güç tasarrufu modu reddetti
      console.log(`wake lock: ${(err as DOMException).name}`);
    }
  }

Wake lock'un ömrü sayfanın görünür olmasına bağlı. MDN'e göre sayfa gizlenince tarayıcı kilidi kendisi bırakıyor ve release event'i geliyor; bırakılan bir sentinel tekrar kullanılamıyor, kullanıcı sekmeye dönünce yeni bir kilit istemek gerekiyor. Service'in constructor'ındaki visibilitychange listener'ı bu yüzden var; Sekmeler yazısında poll'u görünür sekmede tazelemek için kullandığımız event'in aynısı. Kullanıcı "ekranı açık tut" dediyse (wakeLockWanted) sekmeye döndüğünde kilit yeniden alınıyor. Önizleme component'i:

import { httpResource } from '@angular/common/http';
import { Component, ElementRef, inject, input, OnDestroy, viewChild } from '@angular/core';
import { Book } from '../catalog/book';
import { DeviceCapabilities } from '../core/device-capabilities';

@Component({
  selector: 'app-preview-page',
  template: `
    <section #reader class="reader">
      @if (book.hasValue()) {
        <h1>{{ book.value().title }}: sample</h1>
        <div class="tools">
          @if (device.supports.wakeLock) {
            <button class="screen" (click)="device.keepScreenOn(!device.screenOn())">
              {{ device.screenOn() ? 'Let the screen sleep' : 'Keep the screen on' }}
            </button>
          }
          @if (device.supports.fullscreen) {
            <button class="fullscreen" (click)="toggleFullscreen()">
              {{ device.fullscreen() ? 'Exit full screen' : 'Full screen' }}
            </button>
          }
        </div>
        @for (page of pages; track page) {
          <p class="page">
            Sample page {{ page }} of {{ book.value().title }}. (Placeholder text.)
          </p>
        }
      }
    </section>
  `,
  styles: `
    .reader:fullscreen {
      background: #fffdf7;
      padding: 2rem;
      overflow: auto;
    }
    .reader:fullscreen .page {
      font-size: 1.25rem;
      max-width: 40rem;
    }
  `,
})
export class PreviewPage implements OnDestroy {
  readonly isbn = input.required<string>();
  protected readonly book = httpResource<Book>(() => `/api/books/${this.isbn()}`);
  protected readonly device = inject(DeviceCapabilities);
  protected readonly pages = [1, 2, 3];
  private readonly reader = viewChild.required<ElementRef<HTMLElement>>('reader');

  async toggleFullscreen() {
    try {
      // header'lı uygulamanın tamamı değil, yalnız okuyucu tam ekrana geçer
      await this.device.toggleFullscreen(this.reader().nativeElement);
    } catch (err) {
      console.log(`fullscreen: ${(err as Error).name}`);
    }
  }

  ngOnDestroy() {
    // önizlemeden çıkış: ekranı geri ver ve tam ekrandan çık
    void this.device.keepScreenOn(false);
    if (document.fullscreenElement) void document.exitFullscreen();
  }
}
--- 4. sample reader: wake lock and full screen
[chromium] wake lock: acquired
[chromium] another tab in front: reader tab visibilityState=visible, button "Let the screen sleep"
[chromium] full screen: section.reader, :fullscreen background rgb(255, 253, 247)
[chromium] wake lock: released
[chromium] left the reader: fullscreenElement=null

Tam ekrana uygulamanın tamamı değil yalnız section.reader geçti ve :fullscreen pseudo-class'ı ile verdiğimiz arka plan rengi uygulandı. Sayfadan çıkınca ngOnDestroy kilidi bıraktı ve tam ekrandan çıktı. Tam ekrandaki element header'ı kapattığı için test header'daki linke fareyle tıklayamadı; Playwright tıklamanın section.reader'a gittiğini bildirdi ve linki doğrudan tetiklemem gerekti. Kullanıcı da aynı durumda: tam ekrandayken çıkış butonu okuyucunun içinde olmalı. Bookstore'da "Exit full screen" butonu bu yüzden section'ın içinde.

Bu denemede doğrulayamadığım bir şey var: kilidin sayfa gizlenince bırakılması. Başka bir sekmeyi öne getirdim, pencereyi küçülttüm, CDP (Chrome DevTools Protocol; Chromium'u dışarıdan yöneten protokol) ile yeni sekme açtım; Playwright'ın headless Chromium'unda da WebKit'inde de document.visibilityState hep visible kaldı. Gizlenince bırakma davranışı W3C'nin (web standartlarını yayınlayan kuruluş) Screen Wake Lock spec'inden ve MDN'den; gerçek bir cihazda test etmeni öneririm.

Fullscreen API Baseline değil: BCD'ye göre iPhone'daki Safari element'leri tam ekrana alamıyor, bu yalnız iPad'de var ve orada da gizlenemeyen bir çıkış butonu çıkıyor. Bookstore'da supports.fullscreen bu yüzden document.fullscreenEnabled'a bakıyor ve iPhone'da buton hiç görünmüyor. Kurulu bir PWA'da alternatif, manifest'teki "display": "fullscreen".

Geolocation: "Konumumu Kullan" ve En Yakın Depo

Kurumsal Sipariş'te (okul, kütüphane ve şirketlerin toplu siparişi) teslimat en yakın depodan yapılıyor. "Use my location" butonu konumu alıyor ve en yakın depoyu tarayıcıda hesaplıyor; depo listesi public bir endpoint'ten (/api/warehouses) geliyor ve kullanıcının koordinatları sunucuya hiç gitmiyor:

  currentPosition(options: PositionOptions): Promise<GeolocationPosition> {
    return new Promise((resolve, reject) =>
      navigator.geolocation.getCurrentPosition(resolve, reject, options),
    );
  }

  // Bir iznin durumu; kullanıcı tarayıcı ayarlarından değiştirince güncellenir.
  permission(name: PermissionName): Signal<PermissionView> {
    const state = signal<PermissionView>('unknown');
    if (this.browser && 'permissions' in navigator) {
      navigator.permissions.query({ name }).then(
        (status) => {
          state.set(status.state);
          status.addEventListener('change', () => state.set(status.state));
        },
        () => state.set('unknown'), // TypeError: bu tarayıcı bu adı tanımıyor
      );
    }
    return state.asReadonly();
  }
  async useMyLocation() {
    this.locating.set(true);
    this.message.set('Finding your location...');
    try {
      const { coords } = await this.device.currentPosition({
        enableHighAccuracy: false, // şehir seviyesi yeter; GPS pil ve zaman harcar
        timeout: 10_000,
        maximumAge: 5 * 60_000, // son 5 dakikadaki bir konum yeterli
      });
      const found = nearest(this.warehouses.value() ?? [], coords);
      console.log(
        `location: accuracy ${Math.round(coords.accuracy)} m, nearest ${found?.warehouse.code}`,
      );
      this.message.set(
        found
          ? `Nearest warehouse: ${found.warehouse.name} (${found.km} km).`
          : 'Warehouses could not be loaded.',
      );
    } catch (err) {
      const error = err as GeolocationPositionError;
      console.log(`location: error ${error.code} ${error.message}`);
      const messages: Record<number, string> = {
        [error.PERMISSION_DENIED]:
          'Without your location we cannot pick a warehouse. Type the address instead.',
        [error.POSITION_UNAVAILABLE]:
          'Your location is not available right now. Type the address instead.',
        [error.TIMEOUT]: 'Finding your location took too long. Try again or type the address.',
      };
      this.message.set(messages[error.code]);
    } finally {
      this.locating.set(false);
    }
  }
export interface Warehouse {
  code: string;
  name: string;
  latitude: number;
  longitude: number;
}

// Büyük daire mesafesi (haversine); "hangi depo en yakın" için fazlasıyla yeterli.
export function distanceKm(
  a: { latitude: number; longitude: number },
  b: { latitude: number; longitude: number },
) {
  const rad = (deg: number) => (deg * Math.PI) / 180;
  const dLat = rad(b.latitude - a.latitude);
  const dLon = rad(b.longitude - a.longitude);
  const h =
    Math.sin(dLat / 2) ** 2 +
    Math.cos(rad(a.latitude)) * Math.cos(rad(b.latitude)) * Math.sin(dLon / 2) ** 2;
  return 2 * 6371 * Math.asin(Math.sqrt(h));
}

// Tarayıcıda çalışır: kullanıcının koordinatları sunucuya gönderilmez.
export function nearest(
  warehouses: Warehouse[],
  position: { latitude: number; longitude: number },
) {
  const ranked = warehouses
    .map((warehouse) => ({ warehouse, km: Math.round(distanceKm(position, warehouse)) }))
    .sort((x, y) => x.km - y.km);
  return ranked[0];
}

Seçenekler bilinçli. MDN'e göre varsayılanlar enableHighAccuracy: false, timeout: Infinity ve maximumAge: 0. Sonsuz timeout, konum gelmezse butonun sonsuza kadar "Finding your location..." demesi demek; 10 saniyelik bir sınır koyduk. W3C Geolocation spec'ine göre timeout, kullanıcının izin penceresinde düşündüğü süreyi saymıyor, konum alınmaya başladığında işliyor. Şehir seviyesi bir konum yettiği için yüksek doğruluk istemiyoruz (GPS (uydu tabanlı konum sistemi) daha yavaş ve pil tüketiyor) ve 5 dakika içindeki bir konumu kabul ediyoruz. Üç durum Chromium'da:

--- 5. bulk delivery: Use my location (geolocation granted by Playwright, fake fixed position)
[chromium] location: accuracy 30 m, nearest ANK
[chromium] message: "Nearest warehouse: Ankara warehouse (231 km)."
--- 6. the user blocks location in the browser settings while the page is open (CDP Browser.setPermission)
[chromium] button visible=false, hint "Location is blocked for Bookstore in your browser settings. Type the address instead."
--- 8. no decision yet: the page shows the button, the browser asks (headless answers by itself)
[chromium] Playwright context whose permissions list leaves out geolocation: state denied, button visible=false
[chromium] fresh profile: state prompt, button visible=true
[chromium] location: error 1 User denied Geolocation
[chromium] message: "Without your location we cannot pick a warehouse. Type the address instead.", state now denied

İzin verilmiş bir context'te, Playwright'ın verdiği sabit konumla (Konya şehir merkezi, gerçek bir kullanıcının konumu değil) en yakın depo Ankara çıktı. 6. adımda sayfa açıkken izni CDP üzerinden denied yaptım; bu, kullanıcının tarayıcı ayarlarından konumu kapatmasına karşılık geliyor. PermissionStatus'un change event'i geldi, buton kayboldu ve adresi elle yazma önerisi göründü; sayfayı yenilemeye gerek kalmadı. 8. adımda taze bir profilde durum prompt: buton görünüyor ve tarayıcı tıklamada soruyor. Headless Chromium bu soruyu kendisi reddetti (error 1, PERMISSION_DENIED) ve durum denied oldu; gerçek kullanıcının izin penceresini burada göremedim. 8. adımın ilk satırı ise sayfanın daha açılırken denied durumunu gördüğü hâl: buton hiç çizilmedi. WebKit 27.2'de 5. ve 8. adımlar aynı sonucu verdi; 6. adım CDP gerektirdiği için yalnız Chromium'da çalıştı.

Konumu sürekli izlemek için watchPosition var; her yeni konumda callback çağrılıyor ve clearWatch ile duruyor. Lab sayfasında konumu Playwright ile iki kez küçük adımlarla değiştirdim:

// watchPosition: clearWatch()'a kadar her yeni konumda bir callback
let watchId = null;

document.getElementById("watch").addEventListener("click", () => {
  watchId = navigator.geolocation.watchPosition(
    ({ coords }) =>
      console.log(`position ${coords.latitude.toFixed(4)}, ${coords.longitude.toFixed(4)} (accuracy ${coords.accuracy} m)`),
    (err) => console.log(`watch error ${err.code}: ${err.message}`),
    { enableHighAccuracy: true, maximumAge: 0 },
  );
});

document.getElementById("stop").addEventListener("click", () => {
  navigator.geolocation.clearWatch(watchId);
  console.log("watch stopped");
});
[chromium] position 37.8719, 32.4844 (accuracy 30 m)
[chromium] watch error 2: 
[chromium] position 37.8759, 32.4884 (accuracy 30 m)
[chromium] watch error 2: 
[chromium] position 37.8799, 32.4924 (accuracy 30 m)
[chromium] watch stopped
[webkit] position 37.8719, 32.4844 (accuracy 30 m)
[webkit] position 37.8759, 32.4884 (accuracy 30 m)
[webkit] position 37.8799, 32.4924 (accuracy 30 m)
[webkit] watch stopped

Her konum değişikliği bir callback getirdi; clearWatch'tan sonraki değişiklik gelmedi. Chromium'un emülasyonu her değişiklikten önce bir kez mesajsız error 2 (POSITION_UNAVAILABLE) verdi, ama watch durmadı ve bir sonraki konum geldi. WebKit'te bu ara hata yoktu. Bunun gerçek bir cihazda olup olmadığını doğrulayamadım, ama ders genel: watchPosition'ın error callback'inde geçici hatalarda (2 ve 3) izlemeyi bitirme, yalnız 1'de (izin yok) bitir. Bookstore'un teslimat formuna watchPosition gerekmediği için onu uygulamaya koymadım.

Destek Durumu

Bu altı API'nin durumu web-features 3.46 ve BCD 8.1.9'a göre (Baseline'ı nasıl okuduğumuzu serinin bir önceki yazısında anlatmıştık):

APIBaselineFirefoxSafari / iOSPlaywright'ta gözlenen
Async ClipboardNewly available (Haziran 2024)12713.1 / 13.4Tıklamada çalıştı, timer'dan reddedildi
ClipboardItem.supports()Newly available (Mart 2025)12718.4 / 18.4image/png var, image/jpeg yok
Web ShareDeğilMasaüstünde yalnız flag, Android 7912.1 / 12.2Chromium'da pencere cevapsız kaldı
Screen Wake LockNewly available (Mart 2025)12616.4 / 18.4Aktivasyon gerekmedi
Fullscreen APIDeğil6416.4 / yalnız iPadTıklamada çalıştı, aktivasyonu tüketti
Permissions APIWidely available4616 / 16clipboard-* adları WebKit'te TypeError
GeolocationWidely available3.55 / ilk sürümlerden beriHeadless'ta izin sorusu kendiliğinden reddedildi

Angular 22'nin tarayıcı hedefi Safari 17 ve Firefox 119'dan başladığı için bu tablodaki "newly available" satırların hepsi, hedeflenen tarayıcıların bir kısmında yok demek: ClipboardItem.supports() Safari 18.4'ten, wake lock iOS 18.4'ten önce yok. Service'teki her supports kontrolü bu yüzden yerinde.

Üretimde Dikkat

Permissions-Policy'yi unutma. Bu API'lerin hepsi bir Permissions-Policy direktifiyle (clipboard-write, web-share, screen-wake-lock, fullscreen, geolocation) kontrol ediliyor ve MDN'e göre varsayılan izin listeleri self: kendi sayfanda çalışır, başka origin'den gelen bir iframe'de allow attribute'u olmadan çalışmaz. Tersi de geçerli: kullanmadığın API'leri sunucudan gelen Permissions-Policy header'ıyla kapatmak, sayfaya giren üçüncü parti bir script'in konum istemesini engeller. Bu header'ın ayrıntısı Frontend Güvenliği serisinin "Üçüncü Parti Script'ler, iframe'ler ve postMessage: sandbox, frame-ancestors, Permissions-Policy ve Güvenli Mesajlaşma" yazısında.

SSR'da tarayıcı nesnelerine dikkat. Node.js 21'den beri global bir navigator var; Node 24'te kontrol ettim:

$ node -e "console.log(typeof navigator, navigator.userAgent, typeof navigator.clipboard, 'share' in navigator, 'geolocation' in navigator, typeof document)"
object Node.js/24 undefined false false undefined

navigator var ama içinde tarayıcı API'leri yok, document ise hiç yok. Yani 'share' in navigator sunucuda hata vermeden false dönüyor, ama document.addEventListener ya da matchMedia çağıran bir constructor sunucuda patlıyor. isPlatformBrowser kontrolünü service'in girişinde bir kez yapmak bu yüzden en güvenlisi. Bookstore'un istemcisi SSR'sız olduğu için bu kontrolü bir SSR build'inde çalıştırmadım.

Ölç: paylaşım sonuçlarını (shared, cancelled, copied, failed) ayrı say; artan bir failed oranı bir tarayıcı farkına ya da Permissions-Policy sorununa işaret eder.

Sık Düşülen Tuzaklar

  • Sayfa açılır açılmaz konum ya da pano izni istemek. Kullanıcı bağlamı bilmeden reddeder ve tarayıcı bir daha sormaz; izni değeri belli olan bir butonun tıklamasında iste (bildirim izni için Web Push yazısında aynı kuralı uygulamıştık).
  • Bir tıklamada aktivasyonu tüketen iki işlem yapmak. Tam ekrandan sonra gelen share reddedilir; ikisini ayrı butonlara ayır.
  • Görseli hazırlamak için tıklamanın içinde uzun süre bekleyip clipboard.write()'ı sonra çağırmak. Aktivasyonun süresi dolar; ClipboardItem'a promise ver.
  • Paylaşımı desteklemeyen masaüstü tarayıcıda boş ya da hata veren bir "Share" butonu göstermek. Feature detection ile "Copy link"e düş, ikisi de yoksa butonu hiç gösterme.
  • AbortError'u hata olarak göstermek. Kullanıcı paylaşım penceresini kapattı; bu bir cevap.
  • Wake lock'u alıp bırakmamak ya da sayfa gizlenince bırakıldığını unutmak. Sayfadan çıkarken bırak, visibilitychange'te gerekiyorsa yeniden al.
  • Geolocation'da varsayılan timeout ile yetinmek. Varsayılan sonsuz; kullanıcı sonsuza kadar bekleyen bir buton görür.
  • SSR'da document, window ya da matchMedia'ya doğrudan erişmek. Node'da navigator var diye tarayıcıda olduğunu sanma.

Küçük Bir Deneme

★☆☆ /api/warehouses public ve nadiren değişen bir liste. Angular Service Worker yazısındaki ngsw-config.json'a bu endpoint için performance stratejili bir data group ekle ve "Use my location"ın offline'da da çalıştığını göster.

★★☆ Kitap sayfasına dosya paylaşımı ekle: "Copy as image"in ürettiği PNG'yi File olarak navigator.share({ files }) ile paylaş. Önce navigator.canShare({ files }) ile kontrol et; desteklenmiyorsa görseli panoya kopyalamaya düş.

★★★ Teslimat sayfasına, DeviceCapabilities.permission('geolocation') prompt iken önce kendi açıklama ekranını gösteren ve tarayıcının izin penceresini ancak kullanıcı "Devam" deyince açan küçük bir akış ekle. Playwright'ta taze bir Chromium profiliyle "Devam"a basılmadan tarayıcının hiç sormadığını doğrula.

Bu ek yazıyla Bookstore'un Angular istemcisi cihazla da konuşuyor: linki paylaşıyor ya da kopyalıyor, sipariş numarasını ve kitap kartını panoya koyuyor, önizlemede ekranı açık tutup tam ekrana geçiyor ve kurumsal müşteriye en yakın depoyu buluyor. Bu API'lerin etkileşim süresine ve pil tüketimine etkisini ölçmek Web Performansı serisinin konusu.