Mert Özen Further With Every Line
Frontend Browser APIs and PWA — #11

Browser APIs That Talk to the Device: Async Clipboard, Web Share, Screen Wake Lock, Fullscreen, Permissions and Geolocation

Mert Özen Nov 15, 2026 22 min 10 views
Browser APIs That Talk to the Device: Async Clipboard, Web Share, Screen Wake Lock, Fullscreen, Permissions and Geolocation

You call the clipboard, share, fullscreen and location APIs from inside a click, read the permission state up front with the Permissions API and give each a feature-detected fallback; apart from the wake lock, they are rejected without a user activation. In Bookstore we put them in one DeviceCapabilities service and show, in Chromium 158 and WebKit 27.2, how the activation gets used up, why ClipboardItem should get a promise, and a nearest-warehouse lookup that keeps the location in the browser.

Version note: The examples in this post were run with Angular 22.2 (TypeScript 6.0), Node.js 24 and Playwright 1.65; browser output comes from Playwright's Chromium 158 and WebKit 27.2 builds, on a production build. Permissions that need a user's decision were granted through Playwright context permissions; the location is a fixed, made-up position supplied by Playwright. Support data was read from MDN browser-compat-data 8.1.9 and web-features 3.46; Firefox and real Safari/iOS/Android devices could not be run in this environment, so statements about them come from that data and from the browser vendors' documentation.

Predict First

We are adding an e-book preview page to the Angular client of Bookstore, the online bookstore used as the shared example across this site's series. To make reading feel "complete", a teammate puts three lines into the component's ngOnInit: put the reader in full screen (requestFullscreen), keep the screen from going dark (navigator.wakeLock.request('screen')) and copy the book's link to the clipboard (navigator.clipboard.writeText). The user opens the preview link from an e-mail, so they arrive without ever clicking on the page. Which of the three calls work?

This extra post is about the small device APIs that almost every app asks for: async Clipboard, Web Share, Screen Wake Lock, Fullscreen, Permissions and Geolocation. In Bookstore they all sit behind one DeviceCapabilities service.

The Common Rules: Secure Context, User Activation and Permission State

These APIs share three rules. The first is the secure context: they all work only over HTTPS or on localhost/127.0.0.1 (isSecureContext). The second is user activation, a real interaction of the user with the page such as a click or a key press: some APIs can be called only right after one. The third is permission: some ask the user, some grant without asking. Rather than write from memory which one needs activation, I made the same calls on a framework-free lab page (web/device/), once from a click and once from a timer 7 seconds after the page loaded:

// Which device APIs need a user activation? The same calls from a click and from a timer.
const calls = {
  writeText: () => navigator.clipboard.writeText("1051"),
  fullscreen: () => document.documentElement.requestFullscreen(),
  wakeLock: () => navigator.wakeLock.request("screen"),
  // two APIs that consume the activation, one after the other in the same click
  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;
  // a share sheet can stay open for a long time: do not wait for it
  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, " ");
}

// long after any user activation has expired
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

The answer to the prediction is in the timer lines: of the calls without a click, only the wake lock worked. requestFullscreen threw a TypeError, writeText and share a NotAllowedError. WebKit 27.2 gave the same results with different error messages. In this run I granted clipboard-write and screen-wake-lock in the Playwright context (as we will see below, a fresh Chromium profile has both as granted anyway), so in Chromium 158 a granted permission was not enough to write to the clipboard without an activation.

The second important line is fullscreenThenShare: in the same click we asked for full screen first and then for sharing, and share was rejected with "Must be handling a user gesture". Some APIs consume the activation: full screen and share use up what the click granted. WebKit gave the same result in the same order. The rule is simple: one click, one operation that consumes the activation. In Chromium, a share call on its own did not settle within 2 seconds either: in the headless browser there is no user to answer the share request, and the promise stays pending. Playwright's WebKit, on the other hand, treats share as successful at once; I could not see a real share sheet in either engine.

To read the permission state up front there is the Permissions API (navigator.permissions.query). I read it in three starting situations:

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

In a fresh Chromium profile, location and clipboard reading are prompt (the user will be asked), clipboard writing and the wake lock are granted. In WebKit the wake lock is prompt, while clipboard-write and clipboard-read throw a TypeError: according to BCD these two names exist only in Chromium, and Safari and Firefox do not know them. The PermissionName type in TypeScript 6.0's lib.dom.d.ts does not include them either. That is why code that reads permissions always needs an error path. One more warning: in Playwright's Chromium, giving the context a permissions list makes every permission not on the list denied (in WebKit they stayed prompt); do not assume the state you see in tests is the state of a real profile.

Bookstore's DeviceCapabilities Service

Instead of spreading the device APIs over components, I collected them in one service. A component only asks "can this browser do it?" and shows the button accordingly:

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';

// Every device API Bookstore uses goes through this service: feature detection, user activation rules and
// fallbacks live in one place, and components only ask "can I?" and "do it".
@Service()
export class DeviceCapabilities {
  private readonly document = inject(DOCUMENT);
  // no navigator, no window during server-side rendering
  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;
    // the browser releases a wake lock when the page is hidden; take it again when the user comes back
    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),
    );
  }

  // Call these from a click handler: share, clipboard writes and fullscreen need a user activation.

supports is computed once, when the service is created. There is a reason the checks are written as 'share' in navigator: in TypeScript 6.0's types navigator.share always looks defined, so the usual check does not compile:

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?

The second line compiles and also gives the right answer at run time. The isPlatformBrowser check is for server-side rendering; Bookstore's client has no SSR, but the service must not break when it moves into an SSR project (In Production looks at how Node.js behaves here).

Async Clipboard: Text, Images and the paste Event

The JavaScript Basics series post "The JavaScript BOM: window, location, navigator and Timers" touched on navigator.clipboard in a single line. The service's clipboard and sharing methods:

  async share(data: ShareData): Promise<ShareResult> {
    if (this.supports.share && navigator.canShare(data)) {
      try {
        await navigator.share(data);
        return 'shared';
      } catch (err) {
        // AbortError: the user closed the share sheet; that is an answer, not an error
        return (err as DOMException).name === 'AbortError' ? 'cancelled' : 'failed';
      }
    }
    // no share sheet (e.g. Firefox on the desktop) or data it cannot share: copy the link instead
    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 {
      // pass the promise, not the blob: write() starts inside the click, the image follows
      await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
      return true;
    } catch (err) {
      console.log(`clipboard: ${(err as DOMException).name}`);
      return false;
    }
  }

On the "My orders" page, every order has a "Copy number" button next to it; the customer pastes the number into an e-mail to the support team:

    @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>

The "Copy as image" button on the book page draws the book's title, author and price on a canvas and puts it on the clipboard as a PNG, so it can be pasted into chat apps as an image:

// A small PNG "card" for chat apps: title, author and price drawn on a canvas.
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',
    ),
  );
}

We give ClipboardItem a Promise<Blob>, not the Blob itself. I measured why in a separate run: waiting inside the click and preparing the image afterwards, versus calling write() immediately and handing over the image as a promise:

[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

Waiting 1.5 seconds was fine in both engines, but when the image was ready after 6 seconds, the write() call was rejected because the activation had expired. With a promise, write() started inside the click, so the copy succeeded even though the image arrived 6 seconds later. If you download the image from a server or compress a large canvas, this difference is real. The examples in WebKit's Async Clipboard announcement also give ClipboardItem promises as values. You can ask about format support with ClipboardItem.supports(): both engines supported image/png and neither supported image/jpeg. The run against the app in Chromium:

--- 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

The image read back from the clipboard is image/png, 600x300 and about 15 KB. Reading the clipboard (readText, read) is more sensitive: Chromium asks for the clipboard-read permission; according to WebKit's 2020 announcement, Safari shows a "Paste" menu when another origin wrote the content, and according to BCD Firefox asks for a similar confirmation. To read it back in the test I granted clipboard-read to the Chromium context; the app never reads the clipboard.

To get at what the user pastes, no read permission is needed: the paste event arrives when the user presses Ctrl+V or picks Paste from a menu, and the data is inside the event. In the institutional delivery address form we clean up an address copied from an e-mail, with stray spaces and blank lines:

  // The paste event needs no permission: the user chose to paste. Tidy up addresses copied from e-mails.
  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"

In Chromium I put the text on the clipboard and pressed the paste shortcut with Playwright's keyboard. Because Playwright does not support the clipboard-read permission in WebKit, there I created and dispatched a paste event with the same text; the result was the same.

navigator.share opens the operating system's share sheet: messaging apps, e-mail, AirDrop. share() has a sibling called canShare(), which tells you in advance whether the data can be shared, especially when sharing files. The button on the book page:

      <a [routerLink]="['/books', isbn(), 'preview']">Read a sample</a>
      <!-- no share sheet and no clipboard: no button at all -->
      @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]);
  }

When reading the results, keep two things apart. AbortError means the user closed the share sheet (or there was no target at all); that is not an error but the user's answer, so do not show an error message. If share does not exist, the button says "Copy link" and puts the link on the clipboard; if neither exists, the button does not appear. Step 2 of the output above shows this path: to imitate Firefox on the desktop I opened the page with an init script that deletes navigator.share and canShare before the app starts; the button became "Copy link" and the link went to the clipboard. This is a simulation, not real Firefox.

According to BCD, Chrome on the desktop had Web Share only on Windows and ChromeOS until 128. Making an installed PWA show up in other apps' share sheets is done with share_target in the manifest, and according to web-features it is Chromium-only; we set up the manifest in the installable PWA post.

Screen Wake Lock and Fullscreen: The Preview Screen

The e-book preview uses two APIs: Screen Wake Lock so the screen does not go dark, and the Fullscreen API to put the reader in full screen. The part of the service:

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

  // Keeps the screen on while `on` is true. No user activation needed, but the lock only lives while the page is visible.
  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: denied by a policy, the permission or a power-saving mode
      console.log(`wake lock: ${(err as DOMException).name}`);
    }
  }

A wake lock lives only as long as the page is visible. According to MDN, the browser releases the lock itself when the page is hidden and a release event fires; a released sentinel cannot be reused, so a new lock must be requested when the user comes back to the tab. That is why the service's constructor has a visibilitychange listener, the same event we used in the tabs post to refresh the poll in a visible tab. If the user asked to keep the screen on (wakeLockWanted), the lock is taken again when they return to the tab. The preview component:

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 {
      // only the reader goes full screen, not the whole app with its header
      await this.device.toggleFullscreen(this.reader().nativeElement);
    } catch (err) {
      console.log(`fullscreen: ${(err as Error).name}`);
    }
  }

  ngOnDestroy() {
    // leaving the preview: give the screen back and leave full screen
    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

Only section.reader went full screen, not the whole app, and the background colour we set with the :fullscreen pseudo-class was applied. Leaving the page, ngOnDestroy released the lock and left full screen. Because the full-screen element covers the header, the test could not click the header link with the mouse; Playwright reported that the click went to section.reader, and I had to trigger the link directly. The user is in the same position: in full screen, the exit button has to be inside the reader. That is why Bookstore's "Exit full screen" button sits inside the section.

There is one thing I could not verify in this run: the lock being released when the page is hidden. I brought another tab to the front, minimised the window and opened a new tab over CDP; in both Playwright's headless Chromium and its WebKit, document.visibilityState stayed visible. The release-on-hide behaviour comes from the W3C Screen Wake Lock spec and MDN; I recommend testing it on a real device.

The Fullscreen API is not Baseline: according to BCD, Safari on the iPhone cannot put elements in full screen; only the iPad can, and there it shows an exit button that cannot be hidden. That is why Bookstore's supports.fullscreen looks at document.fullscreenEnabled, and on the iPhone the button never appears. For an installed PWA the alternative is "display": "fullscreen" in the manifest.

Geolocation: "Use My Location" and the Nearest Warehouse

For Institutional Orders, the bulk orders of schools, libraries and companies, delivery comes from the nearest warehouse. The "Use my location" button gets the position and computes the nearest warehouse in the browser; the warehouse list comes from a public endpoint (/api/warehouses) and the user's coordinates never reach the server:

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

  // The state of a permission, kept up to date when the user changes it in the browser's settings.
  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: this browser does not know the name
      );
    }
    return state.asReadonly();
  }
  async useMyLocation() {
    this.locating.set(true);
    this.message.set('Finding your location...');
    try {
      const { coords } = await this.device.currentPosition({
        enableHighAccuracy: false, // city level is enough; GPS costs battery and time
        timeout: 10_000,
        maximumAge: 5 * 60_000, // a position from the last 5 minutes is fine
      });
      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;
}

// Great-circle distance (haversine); plenty for "which warehouse is closest".
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));
}

// Runs in the browser: the user's coordinates are not sent to the server.
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];
}

The options are deliberate. According to MDN, the defaults are enableHighAccuracy: false, timeout: Infinity and maximumAge: 0. An infinite timeout means the button can say "Finding your location..." forever if no position arrives; we set a 10-second limit. According to the W3C Geolocation spec, the timeout does not count the time the user spends on the permission prompt; it runs once acquiring the position begins. A city-level position is enough, so we do not ask for high accuracy (GPS is slower and costs battery), and we accept a position from the last 5 minutes. Three situations in Chromium:

--- 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

In a context with the permission granted and Playwright's fixed position (Konya city centre, not a real user's location), the nearest warehouse is Ankara. In step 6 I set the permission to denied over CDP while the page was open; this corresponds to the user turning location off in the browser settings. The change event of PermissionStatus fired, the button disappeared and the suggestion to type the address appeared, without a reload. In step 8 a fresh profile starts at prompt: the button is shown and the browser asks on the click. Headless Chromium rejected the question on its own (error 1, PERMISSION_DENIED) and the state became denied; I could not see a real user's permission prompt here. The first line of step 8 is the case where the page already sees denied while loading: the button is never drawn. In WebKit 27.2 steps 5 and 8 gave the same results; step 6 needs CDP, so it ran in Chromium only.

To follow the position continuously there is watchPosition; the callback is called for every new position, and clearWatch stops it. On the lab page I moved the position twice in small steps with Playwright:

// watchPosition: one callback per new position until clearWatch()
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

Every change brought a callback; the change after clearWatch did not. Chromium's emulation reported error 2 (POSITION_UNAVAILABLE) with no message once before each change, but the watch kept going and the next position arrived. WebKit had no such intermediate error. I could not verify whether this happens on a real device, but the lesson is general: in the error callback of watchPosition, do not stop watching on temporary errors (2 and 3); stop only on 1 (no permission). Bookstore's delivery form does not need watchPosition, so it is not in the app.

Support Status

The status of these six APIs according to web-features 3.46 and BCD 8.1.9 (we covered how to read Baseline in the previous post of the series):

APIBaselineFirefoxSafari / iOSObserved in Playwright
Async ClipboardNewly available (June 2024)12713.1 / 13.4Worked from a click, rejected from a timer
ClipboardItem.supports()Newly available (March 2025)12718.4 / 18.4image/png yes, image/jpeg no
Web ShareNoDesktop behind a flag only, Android 7912.1 / 12.2Share sheet left unanswered in Chromium
Screen Wake LockNewly available (March 2025)12616.4 / 18.4No activation needed
Fullscreen APINo6416.4 / iPad onlyWorked from a click, consumed the activation
Permissions APIWidely available4616 / 16clipboard-* names throw TypeError in WebKit
GeolocationWidely available3.55 / since the first versionsHeadless rejected the permission question on its own

Angular 22's browser target starts at Safari 17 and Firefox 119, so every "newly available" row in this table is missing in part of the targeted browsers: ClipboardItem.supports() arrived in Safari 18.4 and the wake lock in iOS 18.4. Every supports check in the service is there for that reason.

In Production

Do not forget Permissions-Policy. All of these APIs are controlled by a Permissions-Policy directive (clipboard-write, web-share, screen-wake-lock, fullscreen, geolocation), and according to MDN their default allowlists are self: they work on your own page, but not in an iframe from another origin without an allow attribute. The reverse also holds: turning off the APIs you do not use with a Permissions-Policy header from the server stops a third-party script on the page from asking for the location. The details of that header are in the Frontend Security series post "Third-Party Scripts, iframes and postMessage: sandbox, frame-ancestors, Permissions-Policy and Safe Messaging".

Watch out for browser objects in SSR. Node.js has had a global navigator since version 21; I checked on Node 24:

$ 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 exists, but without the browser APIs, and document does not exist at all. So 'share' in navigator returns false on the server without an error, but a constructor that calls document.addEventListener or matchMedia blows up there. Doing the isPlatformBrowser check once at the entrance of the service is the safest option. Because Bookstore's client has no SSR, I did not run this check in an SSR build.

Measure: count the share results (shared, cancelled, copied, failed) separately; a rising failed rate points to a browser difference or a Permissions-Policy problem.

Common Pitfalls

  • Asking for location or clipboard permission as soon as the page opens. The user rejects without context and the browser does not ask again; ask on the click of a button whose value is clear (we applied the same rule to the notification permission in the Web Push post).
  • Doing two activation-consuming operations in one click. A share after full screen is rejected; give them separate buttons.
  • Waiting a long time inside the click to prepare an image and calling clipboard.write() afterwards. The activation expires; give ClipboardItem a promise.
  • Showing an empty or failing "Share" button in a desktop browser without sharing. Fall back to "Copy link" with feature detection, and if neither exists, do not show the button.
  • Showing AbortError as an error. The user closed the share sheet; that is an answer.
  • Taking a wake lock and never releasing it, or forgetting that it is released when the page is hidden. Release it when leaving the page and take it again on visibilitychange if needed.
  • Settling for the default timeout in Geolocation. The default is infinite; the user sees a button that waits forever.
  • Touching document, window or matchMedia directly during SSR. Node having a navigator does not mean you are in a browser.

A Small Experiment

★☆☆ /api/warehouses is a public list that rarely changes. Add a data group with the performance strategy for this endpoint to ngsw-config.json from the Angular Service Worker post, and show that "Use my location" also works offline.

★★☆ Add file sharing to the book page: share the PNG produced by "Copy as image" as a File with navigator.share({ files }). Check with navigator.canShare({ files }) first; if it is not supported, fall back to copying the image to the clipboard.

★★★ Add a small flow to the delivery page that, while DeviceCapabilities.permission('geolocation') is prompt, first shows your own explanation screen and opens the browser's permission prompt only after the user says "Continue". In Playwright, with a fresh Chromium profile, verify that the browser never asks before "Continue" is pressed.

With this extra post, Bookstore's Angular client also talks to the device: it shares or copies links, puts order numbers and book cards on the clipboard, keeps the screen on and goes full screen in the preview, and finds the nearest warehouse for institutional customers. Measuring the effect of these APIs on interaction time and battery use is a topic for the Web Performance series.