1. 始める前に
プログレッシブ ウェブ アプリケーション(PWA)は、HTML、CSS、JavaScript などの一般的なウェブ技術を使用して構築された、ウェブ経由で提供されるアプリケーション ソフトウェアです。標準準拠のブラウザを使用するすべてのプラットフォームで動作するように設計されています。
この Codelab では、まず基本的な PWA から始めて、最終的に PWA をさらに強化する新しいブラウザ機能について学習します。🦸?
これらの新しいブラウザ機能の多くは現在も標準化されており、利用するにはブラウザのフラグを設定する必要があります。
Prerequisites
この Codelab では、最新の JavaScript、特に Promise と async/await に精通している必要があります。Codelab のすべての手順はすべてのプラットフォームに対応しているわけではないため、Android スマートフォンやノートパソコンなど、コードを編集するデバイスと別のオペレーティング システムのデバイスがある場合は、テストに便利です。実際のデバイスの代わりに、Android シミュレータなどのシミュレータや、現在のデバイスからテストできる BrowserStack などのオンライン サービスを使用することもできます。それ以外の場合は、任意のステップをスキップできます。各ステップは相互に依存しません。
作成するアプリの概要
グリーティング カード ウェブアプリを作成し、ブラウザの新機能や今後リリースされるブラウザ機能でアプリを強化する方法を学びます。これにより、特定のブラウザで高度なエクスペリエンスが提供されるようになります(ただし、すべての最新のブラウザでは引き続き有効です)。
ファイル システムへのアクセス、システム クリップボードへのアクセス、連絡先の取得、定期的なバックグラウンド同期、画面の wake lock、共有機能などをサポートする機能を追加する方法について学びます。
この Codelab を完了すると、新しいブラウザ機能でウェブアプリを段階的に拡張していく方法をしっかりと理解できます。また、互換性のないブラウザを使用している一部のユーザーにダウンロードの負担をかけずに済みます。最も重要な点は、そもそもウェブアプリを除外しないことです。
必要なもの
現時点で完全にサポートされているブラウザは次のとおりです。
特定の Dev チャンネルを使用することをおすすめします。
2. Project Fugu
プログレッシブ ウェブアプリ(PWA)は、最新の API で構築、強化されたことで、進化した機能、信頼性、インストール性を実現すると同時に、世界中の場所やデバイスを問わず、世界中の誰にでもリーチできます。
一部の API は非常に強力であり、正しく処理しないと、問題が発生する可能性があります。フグ魚と同じように 🐡?: カットはデリカシーだが、間違えると致命的になる可能性がある(でも、この Codelab では実際に壊れることはない)。
そのため、Web Capabilities プロジェクトの内部コードネーム(関連する企業がこれらの新しい API を開発しているプロジェクト)は Project Fugu となっています。
現在すでに使用されているウェブ機能によって、大小さまざまな企業が、ブラウザベースの純粋なソリューション上に構築されるようになり、多くの場合、プラットフォーム固有のルートを採用するよりも開発コストを低く抑えて迅速にデプロイできます。
3. 始める
いずれかのブラウザをダウンロードし、about://flags
に移動して Chrome と Edge の両方で次の実行時間フラグを設定します。
#enable-experimental-web-platform-features
有効にしたら、ブラウザを再起動します。
Glitch プラットフォームを使用する。これは、PWA をホストでき、エディタが適切に行われるためです。Glitch は GitHub へのインポートとエクスポートもサポートしているため、ベンダー ロックインは発生しません。fugu-paint.glitch.me に移動してアプリケーションを試します。これは Codelab で改善する基本的な描画アプリです。
アプリで再生したら、アプリをリミックスして、編集できる独自のコピーを作成します。リミックスの URL は glitch.com/edit/#!/bouncy-candytuft のようになります(例: 「bouncy-candytuft"」など)。このリミックスは世界中から直接利用できます。作業内容を保存するには、既存のアカウントにログインするか、Glitch で新しいアカウントを作成してください。[🕶? Show"] ボタンをクリックするとアプリが表示されます。ホストされるアプリの URL は bouncy-candytuft.glitch.me のようになります(トップレベル ドメインは .com
ではなく .me
です)。
これで、アプリを編集して改善する準備ができました。変更するたびに、アプリが再読み込みされ、変更が反映されます。
以下のタスクは順番に完了することが理想的ですが、上記のように、互換性のあるデバイスにアクセスできない場合は、いつでもステップをスキップできます。各タスクには、無害な淡水魚である Elevation と、🐡?(注意深く「フグ魚」を扱う)というマークが付いています。マークは、魚の実験的機能かどうかを示すアラートです。
DevTools の Console で、API が現在のデバイスでサポートされているかどうかを確認します。また、Glitch を使用しているので、スマートフォンやデスクトップ パソコンなど、さまざまなデバイスで同じアプリを簡単に確認できます。
4. ⇧ Web Share API サポートを追加する
誰にとっても素晴らしい描画が付いていないと、最も魅力的な描画を作成するのは退屈です。グリーティング カードとしてユーザーが自分の絵を世界と共有できるようにする機能を追加します。
Web Share API はファイル共有をサポートしています。ご存じのとおり、File
は特定の種類の Blob
です。そのため、share.mjs
というファイルで共有ボタンと、toBlob()
でキャンバスの内容を blob に変換し、以下の共有関数を追加します。
これを実装してもボタンが表示されない場合は、ブラウザに Web Share API が実装されていないため、このボタンは表示されません。
import { shareButton, toBlob } from './script.mjs';
const share = async (title, text, blob) => {
const data = {
files: [
new File([blob], 'fugu-greeting.png', {
type: blob.type,
}),
],
title: title,
text: text,
};
try {
if (!navigator.canShare(data)) {
throw new Error("Can't share data.", data);
}
await navigator.share(data);
} catch (err) {
console.error(err.name, err.message);
}
};
shareButton.style.display = 'block';
shareButton.addEventListener('click', async () => {
return share('Fugu Greetings', 'From Fugu With Love', await toBlob());
});
5. ⇧ Web Share Target API サポートを追加する
これで、ユーザーはアプリを使って作成したグリーティング カードを共有できるようになりました。また、ユーザーがアプリに画像を共有してグリーティング カードに変換できるようにすることもできます。その場合は、Web Share Target API を使用できます。
ウェブ アプリケーション マニフェストでは、どのようなファイルを受け付け可能か、1 つまたは複数のファイルが共有されたときにブラウザで呼び出す必要がある URL をアプリに伝える必要があります。以下に、ファイル manifest.webmanifest
の抜粋を示します。
{
"share_target": {
"action": "./share-target/",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"files": [
{
"name": "image",
"accept": ["image/jpeg", "image/png", "image/webp", "image/gif"]
}
]
}
}
}
その後、Service Worker が受信したファイルを処理します。URL ./share-target/
は存在しません。アプリは fetch
ハンドラで URL ハンドラを操作するだけでなく、クエリ パラメータ ?share-target
を追加してリクエストをルート URL にリダイレクトします。
self.addEventListener('fetch', (fetchEvent) => {
/* 🐡 Start Web Share Target */
if (
fetchEvent.request.url.endsWith('/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 mediaCache = await caches.open(
keys.filter((key) => key.startsWith('media'))[0],
);
await mediaCache.put('shared-image', new Response(image));
return Response.redirect('./?share-target', 303);
})(),
);
}
/* 🐡 End Web Share Target */
/* ... */
});
アプリを読み込むと、このクエリ パラメータが設定されているかどうかを確認し、設定されている場合は共有画像をキャンバスに描画して、キャッシュから削除します。これはすべて script.mjs
で行われます。
const restoreImageFromShare = async () => {
const mediaCache = await getMediaCache();
const image = await mediaCache.match('shared-image');
if (image) {
const blob = await image.blob();
await drawBlob(blob);
await mediaCache.delete('shared-image');
}
};
この関数は、アプリの初期化時に使用されます。
if (location.search.includes('share-target')) {
restoreImageFromShare();
} else {
drawDefaultImage();
}
6. ⇧ 画像インポートのサポートを追加する
すべてを一から描画するのは大変です。ユーザーが自分のデバイスからアプリにローカル画像をアップロードできるようにする機能を追加します。
まず、キャンバスで drawImage()
関数を確認します。次に、<input
要素について理解します。
type=file>
この知識を活かして、import_image_legacy.mjs
というファイルを編集して次のスニペットを追加できます。ファイルの先頭にインポート ボタンをインポートし、キャンバスに blob を描画できる便利な関数 drawBlob()
をインポートします。
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png, image/jpeg, image/*';
input.addEventListener('change', () => {
const file = input.files[0];
input.remove();
return resolve(file);
});
input.click();
});
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
7. ⇧ 画像エクスポートのサポートを追加
アプリで作成したファイルをデバイスにどのように保存するか?従来、これは <a
要素を使用して行っていました。
download>
export_image_legacy.mjs
ファイルに、次のように内容を追加します。エクスポート ボタンと、キャンバスの内容を blob に変換する toBlob()
コンビニエンス関数をインポートします。
import { exportButton, toBlob } from './script.mjs';
export const exportImage = async (blob) => {
const a = document.createElement('a');
a.download = 'fugu-greeting.png';
a.href = URL.createObjectURL(blob);
a.addEventListener('click', (e) => {
a.remove();
setTimeout(() => URL.revokeObjectURL(a.href), 30 * 1000);
});
setTimeout(() => a.click(), 0);
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
exportImage(await toBlob());
});
8. ⇧ File System Access API サポートを追加する
共有は思いやりのあるものですが、ユーザーは自分の優れた作業を各自のデバイスに保存したいと思うでしょう。ユーザーが描画を保存(再開)できるようにする機能を追加します。
以前は、ファイル インポートには <input type=file>
レガシー アプローチを使用し、ファイル エクスポートには <a download>
レガシー アプローチを使用していました。今回は、File System Access API を使用して、このエクスペリエンスを改善していきます。
この API を使用すると、オペレーティング システムのファイル システムからファイルを開いて保存できます。以下の内容を追加して、2 つのファイル(それぞれ import_image.mjs
と export_image.mjs
)を編集します。これらのファイルを読み込むには、script.mjs
から 🐡? 絵文字を削除してください。
次の行を置き換えます。
// Remove all the emojis for this feature test to succeed.
if ('show🐡Open🐡File🐡Picker' in window) {
/* ... */
}
次の行を使用します。
if ('showOpenFilePicker' in window) {
/* ... */
}
import_image.mjs
で次のように変更します。
import { importButton, drawBlob } from './script.mjs';
const importImage = async () => {
try {
const [handle] = await window.showOpenFilePicker({
types: [
{
description: 'Image files',
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.avif', '.webp', '.svg'],
},
},
],
});
return await handle.getFile();
} catch (err) {
console.error(err.name, err.message);
}
};
importButton.style.display = 'block';
importButton.addEventListener('click', async () => {
const file = await importImage();
if (file) {
await drawBlob(file);
}
});
export_image.mjs
で次のように変更します。
import { exportButton, toBlob } from './script.mjs';
const exportImage = async () => {
try {
const handle = await window.showSaveFilePicker({
suggestedName: 'fugu-greetings.png',
types: [
{
description: 'Image file',
accept: {
'image/png': ['.png'],
},
},
],
});
const blob = await toBlob();
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
} catch (err) {
console.error(err.name, err.message);
}
};
exportButton.style.display = 'block';
exportButton.addEventListener('click', async () => {
await exportImage();
});
9. ⇧ Contacts Picker API のサポートを追加
ユーザーは、グリーティング カードにメッセージを追加したり、誰かに話しかけたりすることができます。ユーザーが地域の連絡先から 1 人(または複数)を選んで共有できる機能を追加します。
Android または iOS デバイスの場合、Contact Picker API を使用すれば、デバイスの連絡先管理ツール アプリから連絡先を選択して、アプリに返すことができます。contacts.mjs
ファイルを編集して、以下のコードを追加します。
import { contactsButton, ctx, canvas } from './script.mjs';
const getContacts = async () => {
const properties = ['name'];
const options = { multiple: true };
try {
return await navigator.contacts.select(properties, options);
} catch (err) {
console.error(err.name, err.message);
}
};
contactsButton.style.display = 'block';
contactsButton.addEventListener('click', async () => {
const contacts = await getContacts();
if (contacts) {
ctx.font = '1em Comic Sans MS';
contacts.forEach((contact, index) => {
ctx.fillText(contact.name.join(), 20, 16 * ++index, canvas.width);
});
}
});
10. ↩ 非同期クリップボード API サポートを追加
ユーザーは、別のアプリからアプリに画像を貼り付けたり、アプリから別のアプリに画像をコピーしたりできます。ユーザーが自分のアプリ間で画像をコピーして貼り付けることができる機能を追加します。Async Clipboard API では PNG 画像がサポートされるため、画像データをクリップボードに読み書きできます。
clipboard.mjs
ファイルを見つけて、以下を追加します。
import { copyButton, pasteButton, toBlob, drawImage } from './script.mjs';
const copy = async (blob) => {
try {
await navigator.clipboard.write([
/* global ClipboardItem */
new ClipboardItem({
[blob.type]: blob,
}),
]);
} catch (err) {
console.error(err.name, err.message);
}
};
const paste = async () => {
try {
const clipboardItems = await navigator.clipboard.read();
for (const clipboardItem of clipboardItems) {
try {
for (const type of clipboardItem.types) {
const blob = await clipboardItem.getType(type);
return blob;
}
} catch (err) {
console.error(err.name, err.message);
}
}
} catch (err) {
console.error(err.name, err.message);
}
};
copyButton.style.display = 'block';
copyButton.addEventListener('click', async () => {
await copy(await toBlob());
});
pasteButton.style.display = 'block';
pasteButton.addEventListener('click', async () => {
const image = new Image();
image.addEventListener('load', () => {
drawImage(image);
});
image.src = URL.createObjectURL(await paste());
});
11. ⇧ Badging API サポートの追加
ユーザーがアプリをインストールすると、ホーム画面にアイコンが表示されます。このアイコンを使用して、特定の描画での筆のストローク数など、楽しい情報を伝えることができます。
ユーザーが新しい絵筆をかけるたびにバッジがカウントされる機能を追加します。Badging API を使用すると、アプリアイコンに数値バッジを設定できます。pointerdown
イベントが発生する(ブラシストロークが発生する)たびにバッジを更新し、キャンバスがクリアされたらバッジをリセットできます。
次のコードを badge.mjs
ファイルに追加します。
import { canvas, clearButton } from './script.mjs';
let strokes = 0;
canvas.addEventListener('pointerdown', () => {
navigator.setAppBadge(++strokes);
});
clearButton.addEventListener('click', () => {
strokes = 0;
navigator.setAppBadge(strokes);
});
12. ⇧ 画面の起動ロック API サポートを追加する
ときどき、インスピレーションを得るのに十分な時間、絵を見つめるまでの時間が必要になることがあります。画面をスリープ状態から復帰させ、スクリーンセーバーを起動させる機能を追加します。Screen Wake Lock API は、ユーザーの画面がスリープ状態になるのを防ぎます。ページの表示で定義されている公開設定変更イベントが発生すると、wake lock は自動的に解放されます。そのため、ページが再表示された場合は wake lock を再取得する必要があります。
wake_lock.mjs
ファイルを見つけて、その内容を追加します。これをテストするために、1 分後にスクリーンセーバーが表示されるように設定してください。
import { wakeLockInput, wakeLockLabel } from './script.mjs';
let wakeLock = null;
const requestWakeLock = async () => {
try {
wakeLock = await navigator.wakeLock.request('screen');
wakeLock.addEventListener('release', () => {
console.log('Wake Lock was released');
});
console.log('Wake Lock is active');
} catch (err) {
console.error(err.name, err.message);
}
};
const handleVisibilityChange = () => {
if (wakeLock !== null && document.visibilityState === 'visible') {
requestWakeLock();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
wakeLockInput.style.display = 'block';
wakeLockLabel.style.display = 'block';
wakeLockInput.addEventListener('change', async () => {
if (wakeLockInput.checked) {
await requestWakeLock();
} else {
wakeLock.release();
}
});
13. ⇧ Periodic Background Sync API のサポートを定期的に追加する
無地のキャンバスで始めると、退屈なものになります。Periodic Background Sync API を使用して、毎日新しい画像でユーザーのキャンバスを初期化できます(例: Unsplash の Daily fugu photo)。
これには、定期的なバックグラウンド同期を登録するファイル periodic_background_sync.mjs
と、その日の画像のダウンロードを処理するファイル image_of_the_day.mjs
の 2 つが必要です。
periodic_background_sync.mjs
で次のように変更します。
import { periodicBackgroundSyncButton, drawBlob } from './script.mjs';
const getPermission = async () => {
const status = await navigator.permissions.query({
name: 'periodic-background-sync',
});
return status.state === 'granted';
};
const registerPeriodicBackgroundSync = async () => {
const registration = await navigator.serviceWorker.ready;
try {
registration.periodicSync.register('image-of-the-day-sync', {
// An interval of one day.
minInterval: 24 * 60 * 60 * 1000,
});
} catch (err) {
console.error(err.name, err.message);
}
};
navigator.serviceWorker.addEventListener('message', async (event) => {
const fakeURL = event.data.image;
const mediaCache = await getMediaCache();
const response = await mediaCache.match(fakeURL);
drawBlob(await response.blob());
});
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
periodicBackgroundSyncButton.style.display = 'block';
periodicBackgroundSyncButton.addEventListener('click', async () => {
if (await getPermission()) {
await registerPeriodicBackgroundSync();
}
const mediaCache = await getMediaCache();
let blob = await mediaCache.match('./assets/background.jpg');
if (!blob) {
blob = await mediaCache.match('./assets/fugu_greeting_card.jpg');
}
drawBlob(await blob.blob());
});
image_of_the_day.mjs
で次のように変更します。
const getImageOfTheDay = async () => {
try {
const fishes = ['blowfish', 'pufferfish', 'fugu'];
const fish = fishes[Math.floor(fishes.length * Math.random())];
const response = await fetch(`https://source.unsplash.com/daily?${fish}`);
if (!response.ok) {
throw new Error('Response was', response.status, response.statusText);
}
return await response.blob();
} catch (err) {
console.error(err.name, err.message);
}
};
const getMediaCache = async () => {
const keys = await caches.keys();
return await caches.open(keys.filter((key) => key.startsWith('media'))[0]);
};
self.addEventListener('periodicsync', (syncEvent) => {
if (syncEvent.tag === 'image-of-the-day-sync') {
syncEvent.waitUntil(
(async () => {
try {
const blob = await getImageOfTheDay();
const mediaCache = await getMediaCache();
const fakeURL = './assets/background.jpg';
await mediaCache.put(fakeURL, new Response(blob));
const clients = await self.clients.matchAll();
clients.forEach((client) => {
client.postMessage({
image: fakeURL,
});
});
} catch (err) {
console.error(err.name, err.message);
}
})(),
);
}
});
14. ⇧ Shape Detection API のサポートを追加する
場合によっては、ユーザー(図形描画)や使用されている背景画像にバーコードなどの役立つ情報が含まれることがあります。Shape Detection API(特にバーコード検出 API)を使用すると、この情報を抽出することができます。ユーザーのバーコードを検出する機能を追加します。barcode.mjs
ファイルを見つけて、その内容を追加します。この機能をテストするには、バーコード付きの画像をキャンバスに読み込むか貼り付けます。QR コードの画像検索からバーコードの例をコピーできます。
/* global BarcodeDetector */
import {
scanButton,
clearButton,
canvas,
ctx,
CANVAS_BACKGROUND,
CANVAS_COLOR,
floor,
} from './script.mjs';
const barcodeDetector = new BarcodeDetector();
const detectBarcodes = async (canvas) => {
return await barcodeDetector.detect(canvas);
};
scanButton.style.display = 'block';
let seenBarcodes = [];
clearButton.addEventListener('click', () => {
seenBarcodes = [];
});
scanButton.addEventListener('click', async () => {
const barcodes = await detectBarcodes(canvas);
if (barcodes.length) {
barcodes.forEach((barcode) => {
const rawValue = barcode.rawValue;
if (seenBarcodes.includes(rawValue)) {
return;
}
seenBarcodes.push(rawValue);
ctx.font = '1em Comic Sans MS';
ctx.textAlign = 'center';
ctx.fillStyle = CANVAS_BACKGROUND;
const boundingBox = barcode.boundingBox;
const left = boundingBox.left;
const top = boundingBox.top;
const height = boundingBox.height;
const oneThirdHeight = floor(height / 3);
const width = boundingBox.width;
ctx.fillRect(left, top + oneThirdHeight, width, oneThirdHeight);
ctx.fillStyle = CANVAS_COLOR;
ctx.fillText(
rawValue,
left + floor(width / 2),
top + floor(height / 2),
width,
);
});
}
});
15. 🐡? Idle Detection API のサポートを追加
キオスクモードのセットアップでアプリが動作していると想定している場合は、一定時間操作しないとキャンバスをリセットできると便利な機能です。Idle Detection API を使用すると、ユーザーがデバイスを操作しなくなったことを検出できます。
idle_detection.mjs
ファイルを見つけて、以下の内容を貼り付けます。
import { ephemeralInput, ephemeralLabel, clearCanvas } from './script.mjs';
let controller;
ephemeralInput.style.display = 'block';
ephemeralLabel.style.display = 'block';
ephemeralInput.addEventListener('change', async () => {
if (ephemeralInput.checked) {
const state = await IdleDetector.requestPermission();
if (state !== 'granted') {
ephemeralInput.checked = false;
return alert('Idle detection permission must be granted!');
}
try {
controller = new AbortController();
const idleDetector = new IdleDetector();
idleDetector.addEventListener('change', (e) => {
const { userState, screenState } = e.target;
console.log(`idle change: ${userState}, ${screenState}`);
if (userState === 'idle') {
clearCanvas();
}
});
idleDetector.start({
threshold: 60000,
signal: controller.signal,
});
} catch (err) {
console.error(err.name, err.message);
}
} else {
console.log('Idle detection stopped.');
controller.abort();
}
});
16. 🐡? File Handling API サポートの追加
もし、ユーザーが画像ファイルだけを調整でき、アプリがポップアップ表示されたらどうでしょうか。File Handling API は、こうした処理を可能にするものです。
PWA を画像のファイル ハンドラとして登録する必要があります。これは、ウェブ アプリケーション マニフェストで発生します。manifest.webmanifest
ファイルの以下の抜粋で、それがわかります。(これはすでにマニフェストに含まれているため、ご自身で追加する必要はありません)。
{
"file_handlers": [
{
"action": "./",
"accept": {
"image/*": [".jpg", ".jpeg", ".png", ".webp", ".svg"]
}
}
]
}
開いているファイルを実際に処理するには、以下のコードをファイル file-handling.mjs
に追加します。
import { drawBlob } from './script.mjs';
const handleLaunchFiles = () => {
window.launchQueue.setConsumer((launchParams) => {
if (!launchParams.files.length) {
return;
}
launchParams.files.forEach(async (handle) => {
const file = await handle.getFile();
drawBlob(file);
});
});
};
handleLaunchFiles();
17. 完了
🎉? おめでとうございます!
Project Fugu 🐡? のコンテキストで開発された優れたブラウザ API が多数あり、この Codelab でほんの少しのスクラッチしか得られない可能性があります。
詳しくは、web.dev のサイトをご覧ください。
しかし、それだけではありません。公開前のアップデートについては、Fugu API トラッカーにアクセスできます。これらのリンクには、発送済みのすべてのプロポーザル、開始時の試用版または開発段階のトライアル、その作業が開始されたすべてのプロポーザル、検討は始まっているもののまだ開始されていないすべてのプロポーザルへのリンクがあります。
この Codelab は Thomas Steiner(@tomayac)によって作成されました。皆様からのご質問にお答えし、フィードバックをお寄せいただければ幸いです。この Codelab にご協力いただいた Hemanth H.M(@GNUmanth)、Christian Liebel(@christianliebel)、Sven May(@Svenmay)、Lars Knudsen(@larsgk)、Jackie Han(@hanguokai)に感謝します。