はじめての Zoom Apps

<h2>■ 前置き</h2>
Zoom Appsは、Zoomクライアントの中へZoomが提供されるサービス以外のソリューションとを連携させるインターフェースが提供され、Zoomミーティング中またはミーティング外で利用いただけるよう用意されています。
ZoomAppsは個人用（アカウント内ユーザ）にて実装いただけますが、Zoom Maketplaceへ開示承認申請をいただくと、アカウントに関係なくすべてのZoomユーザーでご利用いただけるよう公開することも可能です。
<br><br>
今回、ZoomAppsを経由したZoomクライアント内のUI/UXでミーティング中に自身の人物映像と背景画像を合成させるサンプルを説明しています。（2022/12）

<h5>Zoom Apps の特徴</h5>

- ZoomAppsからミーティング中のUXの操作が可能
- Zoomクライアント内Webviewを経由した連携になるためWebサーバの用意が必要
- 提供するWebサービスは「OWASP(Open Web Application Security Project)」ヘッダーを使用する必要がある
- 接続しているユーザの識別やミーティングへの接続時状況はクライアントがアクセスする際に「X-Zoom-App-Context」ヘッダー内でサーバ側へ通知される

<h2>■ 実装前の準備について</h2>
１. <a href="https://marketplace.zoom.us/">https://marketplace.zoom.us/</a> へサインインします。<br>
２. 右上プルダウンより 「Build App」 をクリック<br>

![スクリーンショット 2022-12-07 10.27.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/19895c4b-5208-4512-720a-4b93b2278607.png)

３. 「Zoom Apps」タイルの中の「Create」で登録を進めていきます。 <br>

![スクリーンショット 2022-12-07 10.30.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/d567b23a-decd-7e99-b811-0de1c1770564.png)

４. 「Create a Zoom app」の項目で必要となる以下項目を入力して「Create」で進みます。<br>
- App Name　: ここでは「Workshop Zoom Apps」としています<br>
- Distribution　: ここでは同じアカウント内のユーザのみの利用を目的とするため無効としています<br>

![スクリーンショット 2022-12-07 11.12.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/4159a7ea-208a-99c1-1193-3fc9819e51af.png)

５. 「App credentials」の項目で必要となる以下項目を入力して「Continue」で進みます。<br>
- Home URL : ZoomAppsで用意されたWebインターフェースからユーザがアクセスするサイトURL<br>
- Redirect URL for OAuth : ユーザが連携の利用開始をする際の認証ランディングURL<br>
- Add allow lists : 提供するWebサーバのURLを指定<br>
- Domain allow list : 上記に含まれないドメインを使用する場合には指定（ZoomAppsSDKをCDN経由で使用する場合には「appssdk.zoom.us」を指定）<br>

![1111.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/8c3bca06-460e-935f-dda0-dc19e7d5b20d.png)

６. 「Information」の項目で必要となる以下項目を入力して「Continue」で進みます。<br>
- Short description : 実装の目的について詳細を端的に入力<br>
- Long description : 実装の目的について詳細を具体的に入力<br>
- Company Name : 会社名または所属団体名を入力<br>
- Name : 名前を登録を入力<br>
- Email address : Zoomから重要な仕様変更の情報などご連絡させていただく際に利用されます。<br>

![スクリーンショット 2022-12-07 10.40.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/d24c8a25-2818-7ff7-6ee4-ee88b64ace19.png)

![スクリーンショット 2022-12-07 10.40.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/13816014-409a-5d9e-93a8-9ffdb7b24c3b.png)

７. 「Feature」の項目で必要となる項目を選択、設定して「Continue」で進みます。
- Event Sunscription : 今回は実装にWebhookを含めないため無効としています。<br>
- Zoom Apps SDK : 連携で必要となる機能を選択します。ここでは必要となる下記を有効にして「Done」で設定します。
　　　　・setVirtualBackground
　　　　・removeVirtualBackground
　　　　・setVirtualForeground
　　　　・removeVirtualForeground
- Guest mode : Zoomミーティングへゲスト接続しているユーザへも利用を許可するかの設定になります。ここでは有効にしています。<br>
- In-client OAuth : 今回は実装されたZoomAppsの利用中にZoomApps以外のZoom APIの利用を目的としていないので無効にしています。
- Collaborate mode : ミーティング参加者とインタラクティブなUXを実現するための機能になりますが今回は無効にしています。

![2222.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/ea5f0a90-6529-9245-d957-7563d2902bc6.png)
![222-1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/a1136602-f799-bbfe-edba-ed14c968b759.png)
![222-2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/bf297c92-c08a-9514-318b-1bfd82af52ba.png)

８. 「Scopes」ではZoom Apps以外のAPI利用においての権限項目を選択できますが、今回は「In-client OAuth」を無効としていますのでデフォルトのまま「Continue」で進みます。<br>

![スクリーンショット 2022-12-07 11.14.59.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/17cc553c-e343-fb06-18a6-3decafe51d27.png)

９. 「Activation」の「Add」、「Add URL」は連携実装が完了後、実際の連携動作を確認する際に使用しますのでここで一旦準備完了となります。<br>

![スクリーンショット 2022-12-07 11.16.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/3925353e-79fa-fbbd-6fca-4e4543a202b6.png)

<h2>■ Webサーバ側の実装について - サーバ・サイド</h2>

以下Node.jsの環境で解説していきます。

１. ファイル名で「index.js」でWebサーバを用意していきます。
```javascript
const express = require('express')
const bodyParser = require('body-parser')
const cors = require('cors')
const crypto = require("crypto")
const app = express()
const port = 4601
const path = require('path')
```
２. フォルダ名「public」内にクライアントからアクセスするファイルを用意していくので、公開場所を指定します。
 
```javascript
app.use(express.static(path.join(__dirname, 'public')))
app.use(bodyParser.json(), cors())
```
３. クライアントからアクセスされた際に全て<a href="https://marketplace.zoom.us/docs/zoom-apps/publishing-an-app/owasp/">OWASP</a>対応にする必要があるので制限事項を明示します。
```javascript
app.use(function(req, res, next) {
  res.header('Strict-Transport-Security', 'max-age=63072000')
  res.header('X-Content-Type-Options', 'nosniff')
  res.header(
    'Content-Security-Policy',
    "default-src *; \
    img-src * data:; \
    media-src *; \
    connect-src *; \
    script-src 'self' 'unsafe-inline' *; \
    style-src 'self' 'unsafe-inline' *;"
  )
  res.header('Referrer-Policy', 'same-origin')
  next()
})
```
４. クライアントがこのZoomAppsを使用許可を得た後のランディングページを明示します。Marketplaceでの登録の際に指定した「Redirect URL for OAuth」に相当します。ZoomとのOAuth認証を得てクライアントに代わりAPI操作を必要とする際にはここでOAuth実装となりますが、今回はクライアント完結型の実装になるので以下のように受け流す形で実装しています。
```javascript
app.get('/oauth', (req, res) => {
  if(req.query.code){
    console.log("code: " + req.query.code);
  }
  var htmlbody = '<html><body>' +
                '<h1>Thank you</h1>' +
                '</body></html>'
  res.status(200)
  res.send(htmlbody)
  res.end()
})
```
５. 起動指定してサーバ側は完了です。
```javascript
app.listen(port, () => console.log(`ZoomApps Sample. port: ${port}!`))
```

<h2>■ Webサーバ側の実装について - クライアント・サイド</h2>

サーバ・サイドで用意した「public」フォルダにクライアントに提供されるファイルを用意していきます。

１. ファイル名「public/index.html」でクライアント側のランディングページを用意します。
今回CDNを使用しているため「sdk.min.js」はCDNの矛先になっています。CDNを使用する際にはMarketplaceの登録時に「Domain allow list」に「appssdk.zoom.us」を明示指定する必要があります。
この後個別に指定している「style.css」、「myzoomapps.js」はこの後の実際の実装のため明示しています。
```html
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
  <link href="css/style.css" rel="stylesheet" type="text/css">
</head>
<body>
<table><tr><th>
<h3>ZoomApps Sample</h3>
<hr />
<p>
 <canvas id="myCanvas" width="1280" height="720" style="border:1px solid #d3d3d3;"></canvas>
</p>
<p>
Preview:<br>
 <img id='image' style="border:1px solid #d3d3d3;">
</p>
<p>
 <input type="text" id="text" name="text">
</p>
<p>
 <button type="button" onclick="setVirtualForeground()">setVirtualForeground()</button>
 <br>
 <button type="button" onclick="removeVirtualForeground()">removeVirtualForeground()</button>
 <br>
 <button type="button" onclick="setVirtualBackground()">setVirtualBackground()</button>
 <br>
 <button type="button" onclick="removeVirtualBackground()">removeVirtualBackground()</button>
</p>
<hr />
</th></tr></table>
<b>Console Log:</b><br>
<p id="console"></p>
  <script src="https://appssdk.zoom.us/sdk.min.js"></script>
  <script src=".js/myzoomapps.js"></script>
</body>
</html>
```

２. 「myzoomapps.js」では最初にZoomAppsを使用するためにイニシャライズ(初期化)から始めます。
この際に使用するZoomAppsのAPIも明示する必要があるためここでは「setVirtualBackground」を明示しています。
また、CDNを使用する場合には「version」の指定も必要となります。ここでは「0.16」としています。
```javascript
const start = async function(a, b) {
  const result = await zoomSdk.config({
    version: '0.16',
    capabilities: [
      "setVirtualBackground"
    ],
  });
  console.log(result);
}
start();
```

３. 続けて実際の挙動を追記していきます。今回はhtml内に配置したキャンバスを編集した画像を背景に合成する仕組み（setVirtualBackground）の他に、半透明な画像処理をすることで人物映像を背景にし編集画像を前面に表示する(setVirtualForeground)を含めています。

```javascript
var c = document.getElementById("myCanvas");
var ctx = c.getContext("2d");
var image = document.getElementById('image'); //Image element

//Box
var imgData = ctx.createImageData(300, 150);
var i;
for (i = 0; i < imgData.data.length; i += 4) {
  //from 0-255; 0 is transparent and 255 is fully visible
  imgData.data[i+0] = 40; // red
  imgData.data[i+1] = 40; // green
  imgData.data[i+2] = 40; // blue
  imgData.data[i+3] = 40; // alpha channel
}

drawing = new Image();
drawing.src = "images/logo.png"; // can also be a remote URL e.g. http://

drawing.onload = function() {
  ctx.clearRect(0, 0, c.width, c.height);      // clear canvas
  ctx.putImageData(imgData, 0, 0);             // Box
  ctx.drawImage(drawing,0,82);                 // image size 300x68 canvas size 300x150 | 82 = 150-68
  image.src = ctx.canvas.toDataURL();          // add image on to img tag
  ctx.drawImage(image, 0, 0 );                 // draw inside img tag
  //console.log(image.src);
}

document.getElementById('text').addEventListener('keyup', function (){
    ctx.clearRect(0, 0, c.width, c.height); // clear canvas
    ctx.putImageData(imgData, 0, 0);
    ctx.drawImage(drawing,0,82);
    //ctx.fillStyle = 'white';
    ctx.font = "bold 60px verdana, sans-serif";
    ctx.fillText(this.value, 0, 60);
    image.src = ctx.canvas.toDataURL();
    //console.log(image.src);
    console.log("keyup:" + this.value);
}, false);

const FIXED_WIDTH = 1280;

async function getImageData(fileUrl) {
  return new Promise((resolve) => {
    const canvas = document.createElement('canvas')
    const img = new Image()
    img.crossOrigin = 'Anonymous'

    img.onload = function () {
      const height = (FIXED_WIDTH * img.height) / img.width

      canvas.width = FIXED_WIDTH
      canvas.height = height

      const ctx = canvas.getContext('2d')

      ctx.drawImage(img, 0, 0, FIXED_WIDTH, height)
      const imageData = ctx.getImageData(0, 0, FIXED_WIDTH, height)

      resolve(imageData)
    }

    img.src = fileUrl
  })
}

var imageid;

async function setVirtualForeground(){
   var imgdata = await getImageData(image.src);

   var myOptions = {
     imageData: imgdata
   };

   console.log("setVirtualForeground");
   imageid = await zoomSdk.setVirtualForeground(myOptions)
   .then((r) => {
       console.log("setVirtualForeground", r);
   })
   .catch((e) => {
       console.log("setVirtualForeground", e);
   });
}

async function removeVirtualForeground() {
    await zoomSdk.callZoomApi("removeVirtualForeground")
    .then(function(r){
      console.log("removeVirtualForeground", r);
    })
    .catch(function(e){
      console.log("removeVirtualForeground",e);
    })
}

async function setVirtualBackground(){
  console.log("setVirtualBackground");
  var myOptions = {
    fileUrl: image.src
  };
  await zoomSdk.setVirtualBackground(myOptions);
}

async function removeVirtualBackground() {
    await zoomSdk.callZoomApi("removeVirtualBackground")
    .then(function(r){
      console.log("removeVirtualBackground", r);
    })
    .catch(function(e){
      console.log("removeVirtualBackground",e);
    })
}
```

<h2>■ 動作確認</h2>
実際に動作確認をするためには、まず「Add」、「Add URL」のいずれかを使用して実装したZoomAppsを使用できるようにします。
この際に使用するZoomユーザは同じアカウント内に所属しているユーザを利用する必要があります。

![画面収録 2022-12-07 14.05.03.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2123842/a40ad5de-8355-078c-977d-76623a682a16.gif)

<h2>■ 実装にあたっての補足事項</h2>

<b>開発コンソールへのアクセスについて</b>
ZoomAppsを利用するクライアント側では開発ツールの利用を制限しているためコンソールログ等を参照することはできません。ただし、クライアント側で明示することで参照できるようにすることができます。取り扱いには注意ください。
詳しくはこちらを参照くださ。https://marketplace.zoom.us/docs/zoom-apps/create-zoom-app/

<b>「X-Zoom-App-Context」ヘッダーについて</b>
サーバ側で誰がアクセスしてきているのかなどヘッダーに含まれる「X-Zoom-App-Context」にて確認していただくことも可能です。
詳しくはこちらを参照ください。https://marketplace.zoom.us/docs/zoom-apps/zoomappcontext/
```javascript:実装例
app.use(function(req, res, next) {
  var zoomAppContext = req.headers["x-zoom-app-context"];
  if(zoomAppContext){
    console.log("zoomAppContext: " + zoomAppContext);
    var dec = decrypt(zoomAppContext, process.env.ZoomAppsOAuthClientSecret)
    console.log("dec: " + JSON.stringify(dec))
    var uid = dec["uid"]
    console.log("uid: " + uid)
  }
  ...
  next()
})
```
<b>公開前のゲスト利用について</b>
ゲスト利用（Guest Mode）を有効にすることでアカウント外ユーザ（別テナントのユーザ）での利用も可能ですが、Marketplaceでの公開申請後、承認されている必要があります。
開発中など公開前で確認されたい場合は、各ゲスト接続されている側で明示する必要があります。
詳しくはこちらを参照ください。https://marketplace.zoom.us/docs/zoom-apps/guides/guest-mode/

<h2>■ サンプル・ドキュメント類について</h2>

<b>今回のサンプル：</b>
ZoomApps Sample: https://github.com/ysawamura-zoom/Workshop/tree/main/ZoomApps


<b>ドキュメント類 (オフィシャル・ドキュメント)：</b>
Introduction to Zoom Apps:　https://marketplace.zoom.us/docs/zoom-apps/introduction/
Developing an App: https://marketplace.zoom.us/docs/zoom-apps/create-zoom-app/
ZoomApps SDK Reference: https://marketplace.zoom.us/docs/zoom-apps/js-sdk/reference/
Guest Mode: https://marketplace.zoom.us/docs/zoom-apps/guides/guest-mode/
Testing Zoom Apps: https://marketplace.zoom.us/docs/zoom-apps/guides/testing-zoom-apps/
Publishing an app: https://marketplace.zoom.us/docs/zoom-apps/publishing-an-app/publishing-an-app/