はじめに
pwaでiOSのpush通知ができるようになるのはいつ頃でしょうか?
日本は特にiOSのシェアが大きいので、pwaがイニシアチブを取れるかどうかはこれにかかっていますね。
さて、web-push-phpもメジャーバージョンがあがって、php7.1以降にも対応するようになり、sendOneNotification()が廃止されたりしているようなので、あらためてこれを使ってpush通知ができるようになるまでをまとめてみます。
ついでに購読者管理を最近私的トレンドのlevelDBでやってみます。
動作確認要件
- CentOS 7
- PHP 7.4.0
- web-push-php 4.0
- levelDB 1.12
事前準備
sudo yum --enablerepo=epel install leveldb-devel
cd /usr/local/src/
git clone https://github.com/reeze/php-leveldb.git
cd php-leveldb/
phpize
./configure --prefix=/home/{you}/.phpenv/versions/7.4.0/lib/php/leveldb --with-libdir=lib64 --with-leveldb=/usr/include/leveldb --with-php-config=/home/{you}/.phpenv/versions/7.4.0/bin/php-config
make
make install
vi ~/.phpenv/versions/7.4.0/etc/php.ini
extension=/home/{you}/.phpenv/versions/7.4.0/lib/php/extensions/no-debug-non-zts-20190902/leveldb.so
~/.phpenv/versions/7.4.0/etc/init.d/php-fpm restart
mkdir -p ~/projects/pwa/{public_html,logs}
cd ~/projects/pwa/
composer require minishlink/web-push
vi composer.json
"minishlink/web-push": "^4.0"
composer update
npm install web-push
./node_modules/.bin/web-push generate-vapid-keys --json > pairs.txt
vi ~/projects/pwa/public_html/vapidPublicKey
{上記pair.txt内の"publicKey"の値}
~/projects/pwa/public_html/index.html
<html>
<head>
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="PWA sandbox">
<link rel="apple-touch-icon" href="/img/icon-192.png" sizes="192x192">
<link rel="apple-touch-icon" href="/img/icon-256.png" sizes="256x256">
<script async src="https://cdn.jsdelivr.net/npm/pwacompat@2.0.6/pwacompat.min.js"
integrity="sha384-GOaSLecPIMCJksN83HLuYf9FToOiQ2Df0+0ntv7ey8zjUHESXhthwvq9hXAZTifA"
crossorigin="anonymous"></script>
<link rel="manifest" href="./manifest.json">
<script src="common.js" defer></script>
</head>
<body>
<h1>PWA sandbox</h1>
<div>test</div>
</body>
</html>
~/projects/pwa/public_html/manifest.json
{
"name": "PWA sandbox",
"short_name": "PWA",
"background_color": "#fff",
"icons": [{
"src": "/img/icon-256.png",
"sizes": "256x256",
"type": "image/png"
},{
"src": "/img/icon-192.png",
"sizes": "192x192",
"type": "image/png"
}],
"start_url": "/?utm_source=homescreen",
"display": "standalone"
}
~/projects/pwa/public_html/service-worker.js
var CACHE_NAME = 'v1-0';
self.addEventListener('install', function(event) {
console.log('[ServiceWorker] Install');
event.waitUntil(
caches.open(CACHE_NAME).then(function(cache) {
return cache.addAll([
'{プリキャッシュしたい画面パス}'
]);
})
);
});
self.addEventListener('activate', function(event) {
console.log('[ServiceWorker] Activate');
event.waitUntil(
caches.keys().then(function(cache) {
cache.map(function(name) {
if(CACHE_NAME !== name) caches.delete(name);
})
})
);
});
self.addEventListener('fetch', function(event) {
console.log('[ServiceWorker] Fetch');
event.respondWith(
caches.match(event.request).then(function(res) {
if(res) return res;
return fetch(event.request);
})
);
});
self.addEventListener('push', function (event) {
console.log('[ServiceWorker] Push');
const title = 'test information';
const options = {
body: event.data.text(),
tag: title,
icon: '/img/icon-256.png',
badge: '/img/icon-256.png'
};
event.waitUntil(self.registration.showNotification(title, options));
});
~/projects/pwa/public_html/common.js
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
navigator.serviceWorker.register('service-worker.js');
navigator.serviceWorker.ready
.then(function(registration) {
return registration.pushManager.getSubscription()
.then(async function(subscription) {
if (subscription) {
return subscription;
}
const response = await fetch('./vapidPublicKey');
const vapidPublicKey = await response.text();
const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey);
return registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: convertedVapidKey
});
});
}).then(function(subscription) {
const key = subscription.getKey('p256dh');
const token = subscription.getKey('auth');
subscription['userPublicKey'] = btoa(String.fromCharCode.apply(null, new Uint8Array(key)));
subscription['userAuthToken'] = btoa(String.fromCharCode.apply(null, new Uint8Array(token)));
fetch('/register.php', {
headers: {'Content-type': 'application/json'},
method: "post",
body: JSON.stringify(subscription)
});
});
~/projects/pwa/common.php
<?php
define('APP_DIR', '/home/{you}/projects/pwa/');
define('LEVEL_DB', 'subscribers');
define('VAPID_SUBJECT', 'mailto:{you}@{your domain}');
define('PUBLIC_KEY', '「事前準備」の最後に取得したpair.txt内の"publicKey"の値');
define('PRIVATE_KEY', '「事前準備」の最後に取得したpair.txt内の"privateKey"の値');
define('LEVEL_DB_BASE_OPTIONS', serialize([
'create_if_missing' => true,
'error_if_exists' => false,
'paranoid_checks' => false,
'block_cache_size' => 8 * (2 << 20),
'write_buffer_size' => 4<<20,
'block_size' => 4096,
'max_open_files' => 1000,
'block_restart_interval' => 16,
'compression' => 1,
'comparator' => NULL,
]));
define('LEVEL_DB_READ_OPTIONS', serialize([
'verify_check_sum' => false,
'fill_cache' => true,
'snapshot' => null
]));
define('LEVEL_DB_WRITE_OPTIONS', serialize([
'sync' => false,
]));
~/projects/pwa/sendpush.php
<?php
require_once 'common.php';
require_once 'vendor/autoload.php';
use Minishlink\WebPush\WebPush;
use Minishlink\WebPush\Subscription;
if (trim($argv[1] ?? '') === '') {
echo "need to input message";
exit(1);
}
$message = $argv[1];
try {
$db = new LevelDB(APP_DIR . LEVEL_DB, unserialize(LEVEL_DB_BASE_OPTIONS), unserialize(LEVEL_DB_READ_OPTIONS), unserialize(LEVEL_DB_WRITE_OPTIONS));
$it = new LevelDBIterator($db);
foreach($it as $endpoint => $pairs) {
$pairs = unserialize($pairs);
send_push($message, $endpoint, $pairs['public_key'] ?? null, $pairs['auth_token'] ?? null);
}
} catch (Exception $e) {
error_log($e->getMessage());
}
function send_push(String $message, String $endpoint, ?String $public_key, ?String $auth_token) {
$subscription = Subscription::create([
'endpoint' => $endpoint,
'publicKey' => $public_key,
'authToken' => $auth_token,
// 'contentEncoding' => 'aes128gcm',
]);
$auth = [
'VAPID' => [
'subject' => VAPID_SUBJECT,
'publicKey' => PUBLIC_KEY,
'privateKey' => PRIVATE_KEY,
]
];
$webPush = new WebPush($auth);
$report = $webPush->sendNotification(
$subscription,
$message,
false,
[],
$auth
);
$webPush->flush();
}
~/projects/pwa/public_html/register.php
<?php
require_once '../common.php';
header("Content-Type: application/json; charset=utf-8");
$reqs = json_decode(file_get_contents('php://input'));
$endpoint = $reqs->endpoint ?? null;
$public_key = $reqs->keys->p256dh ?? null;
$auth_token = $reqs->keys->auth ?? null;
$is = false;
if ($endpoint && $public_key && $auth_token) {
try {
$db = new LevelDB(APP_DIR . LEVEL_DB, unserialize(LEVEL_DB_BASE_OPTIONS), unserialize(LEVEL_DB_READ_OPTIONS), unserialize(LEVEL_DB_WRITE_OPTIONS));
$db->put($endpoint, serialize(compact('public_key', 'auth_token')));
system('find ' . APP_DIR . LEVEL_DB . ' -type d -exec chmod 0770 {} \; && find ' . APP_DIR . LEVEL_DB . ' -type f -exec chmod 0660 {} \;');
$is = true;
} catch (Exception $e) {
error_log($e->getMessage());
}
}
echo json_encode($is);
まとめ
ざーっと書きましたが、フローとそれぞれの役割的には
- ~/projects/pwa/public_html/index.html に相当するURLにアクセス
- ~/projects/pwa/public_html/manifest.json でpwaに関するマニフェストの読み込み
- ~/projects/pwa/public_html/service-worker.js でpush通知等の振る舞いに関する定義の読み込み
- ~/projects/pwa/public_html/common.js でpush通知を要求
- ~/projects/pwa/public_html/register.php で要求されたpush通知の購読者を登録
- ~/projects/pwa/sendpush.php で任意タイミングでpush通知の実施
となります。
sendpush.php はターミナルから以下のように実施してください。
php ~/projects/pwa/sendpush.php {通知したいメッセージ}
これでpush通知を許可した全端末にlevelDBに登録されている購読者をフェッチしながら配信をおこないます。
以下からソースの利用もできます。
https://github.com/cybist/pwa_push_simple_all_in_one