PWAを試してみよう

扱ったことがなかったので、PWA(Progressive Web Apps) を試してみようと思います。

WebページをPWAとして設定することで以下のことができます。

* Webアプリなのに、ネイティブアプリのように、Android/Windowsにアプリとして登録することができる。
* アドレスバーのようなブラウザっぽさはなく、全画面でネイティブアプリのように起動することができる。

PWAのService Workerの機能を使った実装をすることで、以下のことができます。

* Webコンテンツをキャッシュ化することができ、オフラインで動かすことができる。
* サーバ側からPush通知ができる。(iPhoneを除く)

ということで、今回の投稿では、PWAの設定方法と、Push通知の実装をしてみます。

ただ作るだけではモチベーションが上がらないので、パスワード管理アプリ「パスワードマネージャ」を作成します。
世の中にいろいろなツールがありますが、やっぱり自分で管理したいので。。。

毎度ながら、ソースコード一式をGitHubに上げておきました。

poruruba/pwa_test
　https://github.com/poruruba/pwa_test

#PWAの設定

以下を準備する必要があります。

* manifest.jsonを作成し、配備します。
* ServiceWorker起動のためのJavascriptを作成し、配備します。

※ただし、上記を配備するWebサーバは、HTTPSである必要があります。

##manifest.json

こんな感じにします。

```json:public/manifest.json
{
	"short_name": "パスワードマネージャ",
	"name": "パスワードマネージャ",
	"display": "standalone",
	"start_url": "index.html",
	"icons": [
		{
			"src": "img/192x192.png",
			"sizes": "192x192",
			"type": "image/png"
		}
	]
}
```

上述の通り、最低限192x192のpngファイルが必要です。
あとは、このmanifest.jsonをルートに配置し、index.htmlから、以下のようにして読み出すようにします。

```html:public/index.html
・・・
  <link rel="manifest" href="manifest.json">
・・・
```

iOSのSafariのためには、上記の記載の後に以下も追加する必要があるようです。

```html:public/index.html
<link rel="manifest" href="manifest.webmanifest" />
<script async src="https://cdn.jsdelivr.net/npm/pwacompat" crossorigin="anonymous"></script>
```

##Service WorkerのためのJavascript

こんな感じです。

```js:public/sw.js
var CACHE_NAME = 'pwa-sample-caches';
var urlsToCache = [
  // キャッシュ化したいコンテンツ
];

self.addEventListener('install', function(event) {
  console.log('sw event: install called');

  event.waitUntil(
    caches.open(CACHE_NAME)
    .then(function(cache) {
      return cache.addAll(urlsToCache);
    })
  );
});

self.addEventListener('fetch', function(event) {
  console.log('sw event: fetch called');

  event.respondWith(
    caches.match(event.request)
    .then(function(response) {
      return response ? response : fetch(event.request);
    })
  );  
});

self.addEventListener('push', function(event){
  console.log('sw event: push called');

  var notificationDataObj = event.data.json();
  var content = {
    body: notificationDataObj.body,
  };
  event.waitUntil(
    self.registration.showNotification(notificationDataObj.title, content)
  );
});
```

補足します。

```
self.addEventListener('install', function(event) {
self.addEventListener('fetch', function(event) {
```

は、Webコンテンツをキャッシュ化する場合に必要です。
ただし、Webコンテンツをキャッシュ化しなくとも、中身は無くてもよいですが、self.addEventListener('fetch', function(event){ は必要です。

```
self.addEventListener('push', function(event) {
```

は、Push通知を利用する場合に必要です。後述します。

あとは、Webページがロードされたときに、以下を実行すればServiceWorkerが登録されます。Javascriptのファイル名は、sw.jsの前提です。

```js:public/js/start.js
・・・
        if ('serviceWorker' in navigator) {
            navigator.serviceWorker.register('sw.js').then(async (registration) => {
                console.log('ServiceWorker registration successful with scope: ', registration.scope);
            }).catch((err) => {
                console.log('ServiceWorker registration failed: ', err);
            });
        }
・・・
```

ロードされたかどうかは、Chromeの開発ツールで確認することができます。
F12で開発ツールを開いたのち、Applicationタブを選択すると、左側に「Service Workers」があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/f8af4b7b-934b-013c-95e0-e98b717eae70.png)

ServiceWorkerのためのJavascriptはソースファイルを更新しても、Chrome内に登録されたものは更新されませんので、この開発ツールから、UpdeteやUnregisterをすることができます。

で、アドレスバーの右隅に、⊕ があるのがわかりますでしょうか。
これが、このWebページがPWAとして登録できることを示しています。
OSやブラウザによってここらへんの表現は異なります。これをクリックすると、こんなのが表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/552fffcb-e38d-e634-3d47-6765cbdd46e3.png)

「インストール」ボタンを押下すると、PWAアプリとして登録され、完了すると、以下のように単独のページとして表示されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/ea4a3457-a135-ea6b-cb2b-99b9535344e5.png)

これが、PWAのアプリです。アドレスバーがないのがわかります。Webアプリではなくネイティブアプリに見えますよね。
スタートメニューにも、Chromeアプリのところですが、「パスワードマネージャ」が登録されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/07510223-a645-dd8c-c7ea-8edc637cd284.png)

いつでも、これをクリックすることで、アプリとして起動できるようになりました。

#Push通知の実装

（Push通知は、iPhoneは未対応だそうです）

Node.jsのnpmモジュールのおかげで、サーバ側をすぐに立ち上げることができます。

web-push-libs/web-push
　https://github.com/web-push-libs/web-push

また、パスワードマネージャの機能のために、以下のnpm モジュールも使っています。

uuidjs/uuid
　https://github.com/uuidjs/uuid

まず最初にするのが、VapidKeyの生成です。

```js:api/controllers/password/index.js
async function readPasswordFile(apikey){
	try{
		var pwd = await fs.readFile(FILE_BASE + apikey + '.json', 'utf8');
		if( !pwd ){
			pwd = {
				vapidkey : webpush.generateVAPIDKeys(),
				list: [],
				objects: {}		
			};
			await writePasswordFile(apikey, pwd);
		}else{
			pwd = JSON.parse(pwd);
		}
		return pwd;
	}catch(error){
		throw "not found";
	}
}
```

大事なのは以下の部分です。VAPIDKEYという公開鍵ペアの作成です。

```vapidkey : webpush.generateVAPIDKeys()```

最初に生成した後は、同じ値を使うので、上記の関数の中でこの値をファイルに保存しています。ファイルの中身はこんな感じです。

```json:data/password/test.json
{
  "vapidkey": {
    "publicKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "privateKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
  },
  list: [],
  objects: {}
}
```

このVapidKeyのうちの公開鍵vapidkey.publicKeyをクライアントに渡します。以下の部分です。通知先クライアントを区別するために、client_idとしてuuidを生成しそれも一緒に渡しています。

```js:api/controllers/password/index.js
	if( event.path == '/pwd-get-pubkey' ){
		var uuid = uuidv4();
		return new Response({ result: { vapidkey: pwd.vapidkey.publicKey, client_id: uuid } });
	}else
```

クライアント側では以下の処理をしています。

```js:public/js/start.js
    var json = await do_post_apikey(base_url + '/pwd-get-pubkey', {}, this.apikey);

    var object = await registration.pushManager.subscribe({
        userVisibleOnly: true,
        applicationServerKey: json.result.vapidkey
    });
    await do_post_apikey(base_url + '/pwd-put-object', { client_id: json.result.client_id, object: object }, this.apikey);

    this.client_id = json.result.client_id;
    Cookies.set('client_id', this.client_id, { expires: EXPIRES });
```

サーバ側から取得した公開鍵をregistration.pushManager.subscribeに渡しています。
そうすることで、通知のSubscribeが完了し、Push Subscriptionオブジェクトを取得できますので、それをサーバに返してあげます。

Push Subscriptionオブジェクトの内容はこんな感じです。

```
{
　endpoint: "https://fcm.googleapis.com/fcm/send/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
　expirationTime: null
　keys: {
　　auth: "XXXXXXXXXXXXXXXXX"
　　p256dh: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
　}
}
```

一緒に、さっきもらったclient_idも一緒にサーバに戻しておきます。client_idのやり取りはPush通知の実装では必須ではないので、適当な実装でもよいです。

サーバ側ではそのPush Subscriptionオブジェクトを受けとって、後でNotificationするときに必要なので、これもファイルに保存しておきます。

```js:api/controllers/password/index.js
	if( event.path == '/pwd-put-object' ){
		pwd.objects[body.client_id] = body.object;
		await writePasswordFile(apikey, pwd);

		await sendNotification(pwd.vapidkey, pwd.objects[body.client_id], { title: "パスワードマネージャ", body: "通知を登録しました。"} );

		return new Response({});
	}else
```

準備ができたので、後は任意のタイミングで、sendNotification関数を呼び出します。上記のタイミングでも呼び出しています。
中身は以下の感じです。

```js:api/controllers/password/index.js
async function sendNotification(vapidkey, object, content){
	var options = {
		vapidDetails: {
			subject: NOTIFICATION_SUBJECT,
			publicKey: vapidkey.publicKey,
			privateKey: vapidkey.privateKey
		}
	};
	var result = await webpush.sendNotification(object, Buffer.from(JSON.stringify(content)), options);
	if( result.statusCode != 201 )
		throw "status is not 201";
}
```

webpush.sendNotification() に必要なパラメータを設定して呼び出しています。

通知したい内容は、contentのところで指定します。
例えばこんな感じです。

　```{ title: "パスワードマネージャ", body: "通知を登録しました。"}```

このフォーマットは、なんでもよいのですが、受信する側では把握している前提です。

クライアント側の方は、Push通知を受け付けられるように、コールバック関数を実装しておく必要があります。ServiceWorkerのためのJavascriptに実装します。

```js:public/sw.js
self.addEventListener('push', function(event){
  console.log('sw event: push called');

  var notificationDataObj = event.data.json();
  var content = {
    body: notificationDataObj.body,
  };
  event.waitUntil(
    self.registration.showNotification(notificationDataObj.title, content)
  );
});
```

受信したデータのうち、bodyとtitleを使っています。

Push通知を登録したり解除したりできるようにボタンを用意しておきましたので、「Subscribe」ボタンを押してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/1c80ae27-4d1a-41ab-5cde-743a0097cc97.png)

通知が来ました。

1点注意です。
Windowsで、通知をOffにしていると、いつまでたっても通知は受け取れません。
あらかじめ、通知をOnにしておきましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/56815e91-968b-15e6-416c-bed6805c1421.png)

システムの、通知とアクションにあります。
「アプリやその他の送信者からの通知を取得する」 をオンにします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/28f3800f-d32a-bb22-839b-66b963c94b03.png)

#Androidの場合

Androidの場合についても示しておきます。
Webアプリなので、OSに依存せず同じように登録できるのはWebアプリのメリットです。

Androidの場合は、PWAアプリとして登録するのは、Chromeブラウザのメニューから「アプリをインストール」を選択することでインストールされます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/fe19196b-74b9-ec3d-6449-83b422143045.png)

タッチすると以下が表示されます。そのまま、インストールをタップすると、インストールが完了します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9d2256a3-851e-a591-1cd6-05b742806bc9.png)

ホーム画面に登録されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/393aee01-45a1-3f15-0ee3-c55a5b395eea.png)

さっそく、アイコンをタップして起動します。
最初に、スプラッシュ画面が表示された後、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/9ae74efe-f724-8139-8258-4be39496bac4.png)

めでたく、起動できました。
ブラウザで見た画面と同じで、なおかつアドレスバーもありません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/79ab622d-9083-6cb8-0f7e-ba2f3a2ffbb1.png)

以下のようにアプリとして登録されるので、もうネイティブアプリと区別がつきません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/e3844e28-30b4-1e92-7328-c661b4467a93.png)

#その他

パスワードマネージャとしての実装は、GitHubをご参照ください。。。
簡単に、使い方だけ。

トップ画面です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/48f482e2-65fd-afd7-6f27-ff099c15b373.png)

先に、右上のAPI Keyから、apikeyを設定します。
 
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/4dc69891-6d29-2e7f-e903-68a5112b2daa.png)

それに対応するファイルをあらかじめサーバ側に作成しておく必要があります。

　\data\password\＊＊＊＊.json

拡張子.json を抜いたファイル名を指定します。サンプルとして、test.jsを置いておきました。これはバレバレなので、乱数の長めの文字列をファイル名にして他人から推測されないようにしてください。

※当然ですが、パスワードが漏洩するなど、私は一切責任を持ちません！

apikeyを変更した場合は、「F5キー」で表示をリロードしてください。

それでは、パスワードを作成しましょう。「新規作成」ボタンを押下します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/95da29b7-67ef-2d56-2588-7c1c5834dcdf.png)

ここに、記憶したいまたは作成したいユーザIDやパスワードを入力します。パスワードは、自動生成機能を用意しておきました。
nameやurlの入力は任意です。というより、nameを入れないと、他と区別がつかないです。
最後に、「作成」ボタンを押下します。

こんな感じで作成されました。パスワードはサーバ側の先ほどのjsonファイルに保存されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/fc8e390d-5e91-66c5-0bde-33e233efbf5d.png)

Copy列のボタンを押下すれば、パスワードがクリップボードにコピーされます。
変更したい場合は、「変更」ボタンを押下すると、サーバ側に保持した内容を変更します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/802e02b9-f1cb-83ad-3d38-c3c84ba638b3.png)

「変更」ボタンを押下したとき、サーバ側で変更が発生したことを知らせる通知がクライアント側に届くようにしました。

パスワードを削除したい場合は、「削除」ボタンを押下します。

#終わりに

以下のページを参考にさせていただきました。

PWAの作り方をサクッと学ぶ - 「ホーム画面に追加」「キャッシュ操作」「プッシュ通知」の実装
　https://eh-career.com/engineerhub/entry/2019/10/24/103000

PWAのプッシュ通知の仕組み
　https://ajike.github.io/how-pwa-push-works/
　（っていうより、こちらのページの方が説明が丁寧です。。。）

W3C Push API
　https://w3c.github.io/push-api/

Voluntary Application Server Identification for Web Push (VAPID) (RFC 8292)
　https://tools.ietf.org/html/rfc8292

以上