PWA: когда нативные приложения больше не нужны

Никита Дубко, HR Tech Яндекса

PWA: когда нативные приложения больше не нужны

Никита Дубко, HR Tech Яндекса

Никита
Дубко

Web Standards Days в Минске, 2016

2007

2015

Progressive Web Apps: Escaping Tabs Without Losing Our Soul

PWA

Android

iOS

Требования к установке

How to make PWAs installable
caniuse

🤸 Гибкость

📨 Дистрибуция

PWAs in app stores

🔄 Обновление

👇 F5

⚙️ API

🐡 Project Fugu

Fugu tracker
Project Fugu. Is it edible? – Nikita Dubko
Никита Дубко — WebHID API: управление почти чем угодно из браузера
Mozilla, like Google, is looking ahead to the end of Apple's WebKit rule
Checking out and building Chromium for iOS

🤷‍♂️ Да кому оно надо?

Project Fugu API Showcase
Squoosh
Excalidraw
SVGCode

🪄 Вжух

🥷 Прячем кнопку установки

// app.manifest
"related_applications": [
    {
        "platform": "webapp",
        "url": "https://my.app/app.webmanifest"
    }
],
if ('getInstalledRelatedApps' in navigator) {
    const relatedApps =
        await navigator.getInstalledRelatedApps();
    const PWAisInstalled = relatedApps.length > 0;

    if (PWAisInstalled) {
        installButton.classList.add('hidden');
    }
}
PWA Detection
@media (display-mode: standalone),
       (display-mode: window-controls-overlay) {

    .pwa-install-button {
        display: none;
    }

}

💄 Красивая установка

// app.manifest
"short_name": "D&D Tokenizer",
"description": "Some description.",
"screenshots": [
    {
        "src": "./screens/desktop.jpg",
        "type": "image/jpeg",
        "sizes": "800x583",
        "form_factor": "wide"
    },
    {
        "src": "./screens/mobile.jpg",
        "type": "image/jpeg",
        "sizes": "530x700",
        "form_factor": "narrow"
    }
],

⬇️ Получать

// app.manifest
"share_target": {
    "action": "/?share-target",
    "method": "POST",
    "enctype": "multipart/form-data",
    "params": {
        "files": [
            {
                "name": "image",
                "accept": ["image/*"]
            }
        ]
    }
},
// sw.js
self.addEventListener('fetch', (fetchEvent) => {
    const url = new URL(fetchEvent.request.url);
    if (
        url.pathname === '/' &&
        url.searchParams.has('share-target') &&
        fetchEvent.request.method === 'POST'
    ) {
        return fetchEvent.respondWith(
            (async () => {
                const formData = await fetchEvent.request.formData();
                const image = formData.get('image');
                const keys = await caches.keys();
                const sharedCache = await caches.open(
                    keys.filter((key) => key.startsWith('share-target'))[0]
                );
                await sharedCache.put('shared-image', new Response(image));
                return Response.redirect('./?share-target', 303);
            })()
        );
    }
});
Receiving shared data with the Web Share Target API
// main.js
window.addEventListener('load', async () => {
    if (location.search.includes('share-target')) {
        const keys = await caches.keys();
        const sharedCache = await caches.open(
            keys.filter((key) => key.startsWith('share-target'))[0]
        );
        const image = await sharedCache.match('shared-image');
        if (image) {
            const blob = await image.blob();
            await sharedCache.delete('shared-image');
            // do something with blob
        }
    }
});
Receiving shared data with the Web Share Target API

⬆️ Делиться

const shareButton = document.querySelector('.button');
if (navigator.canShare) {
    shareButton.addEventListener('click', async () => {
        const blob = getBlobFromImage();
        const file = new File([blob], 'token.png', {
            type: 'image/png',
        });
        const data = {
            files: [file],
        };
        if (navigator.canShare(data)) {
            try {
                await navigator.share(data);
            } catch (err) {
                if (err.name !== 'AbortError') {
                    console.error(err.name, err.message);
                }
            }
        }
    });
}
Integrate with the OS sharing UI with the Web Share API

📦 Буфер обмена

document.addEventListener('paste', async (e) => {
    e.preventDefault();
    const clipboardItems = typeof navigator?.clipboard?.read === 'function'
            ? await navigator.clipboard.read()
            : e.clipboardData.files;
    for (const clipboardItem of clipboardItems) {
        let blob;
        if (clipboardItem.type?.startsWith('image/')) {
            blob = clipboardItem;
            // do something with blob
        } else {
            const imageTypes = clipboardItem.types?.filter((type) =>
                type.startsWith('image/')
            );
            for (const imageType of imageTypes) {
                blob = await clipboardItem.getType(imageType);
                // do something with blob
            }
        }
    }
});
Unblocking clipboard access

🖥 Нативный UI

// app.manifest
"display": "standalone",
"display_override": ["window-controls-overlay"],
/* style.css */
@media (display-mode: window-controls-overlay) {
    .header {
        padding-right: calc(2 * env(titlebar-area-x));
        padding-left: env(titlebar-area-x);
        height: calc(env(titlebar-area-height) + 10px);
    }
}

.header__title {
    -webkit-app-region: drag;
}
Window Controls Overlay for Installed Desktop Web Apps

📂 Файловая система

const supportsFileSystemAccess =
    'showOpenFilePicker' in window &&
    (() => {
        try {
            return window.self === window.top;
        } catch {
            return false;
        }
    })();

if (supportsFileSystemAccess) {
    let fileHandle = undefined;
    try {
        [fileHandle] = await showOpenFilePicker();
        return await fileHandle.getFile();
    } catch (err) {
        if (err.name !== 'AbortError') {
            console.error(err.name, err.message);
        }
    }
}
Reading and writing files and directories with the browser-fs-access library

👀 Запускать
на старте OS

let promise = navigator.runOnOsLogin.set({
    mode: "windowed",
});

promise.then(function() {
    // Пользователь дал добро
},
function(reason) {
    // Что-то пошло не так
});
Run on OS Login

🎨 EyeDropper API

const eyeDropper = new EyeDropper();

try {
    const result = await eyeDropper.open();
    // Пользователь выбрал пиксель с цветом:
    const colorHexValue = result.sRGBHex;
} catch (err) {
    // Пользователь выключил пипетку
}

🤔 Свой протокол

web+dnd://some.url

// app.manifest
"protocol_handlers": [
    {
        "protocol": "web+tokenizer",
        "url": "/?from=%s"
    }
],
URL protocol handler registration for PWAs

🦉 Приложение вместо ссылки

// app.manifest
"capture_links": "existing_client_event",
"url_handlers": [
    {
        "origin": "https://dnd-tokenizer-41471e.netlify.app"
    }
]
PWAs as URL Handlers

🩳 Шорткаты

// app.manifest
"shortcuts": [
    {
        "name": "Create new token",
        "url": "/"
    }
],
"launch_handler": {
    "client_mode": "focus-existing"
},
Shortcuts Explainer

🌌 Открывать файлы

// app.manifest
"file_handlers": [
    {
        "action": "/",
        "accept": {
            "image/*": [
                ".jpg", ".jpeg", ".webp",
                ".png", ".avif", ".gif"
            ]
        }
    }
],
Handle files in Progressive Web Apps
// main.js
if ('launchQueue' in window) {
    launchQueue.setConsumer((launchParams) => {
        const files = launchParams.files;
        for (const file of files) {
            const blob = await file.getFile();
            blob.handle = file;
            // Сделать что-то с файлом
        }
    });
} else {
    console.error('File Handling API is not supported!');
}

🗄 Своя файловая система

const opfsRoot = await navigator.storage.getDirectory();

const fileHandle = await opfsRoot
    .getFileHandle('file', { create: true });
const directoryHandle = await opfsRoot
    .getDirectoryHandle('folder', { create: true });

const nestedFileHandle = await directoryHandle
    .getFileHandle('nested file', { create: true });
const nestedDirectoryHandle = await directoryHandle
    .getDirectoryHandle('nested folder', { create: true });
        
The origin private file system

📚 Локальные шрифты

try {
    const availableFonts = await window.queryLocalFonts();
    const list = document.querySelector('select.fonts');

    for (const fontData of availableFonts) {
        const option = document.createElement('option');
        option.text = fontData.fullName;
        option.value = fontData.postscriptName;
        if (fontData.fullName === 'Times New Roman') {
            option.selected = true;
        }
        list?.appendChild(option);
    }
} catch (err) {
    console.error(err.name, err.message);
}
Use advanced typography with local fonts

MeFoDy / dnd-tokenizer

Берегите себя!

mefody.dev/talks/pwa-2023/udw.html
@dark_mefody
t.me/mefody_dev QR-код со ссылкой на доклад