electronでエンコードツールを書く技術

副業でelectronでエンコードするクライアントを作ったので知見を残しておく。

# ざっくりの仕様

- GoogleDriveからファイルの取得・アップロード
- 様々な仕組みでのエンコード
- 説明の表示

# ライブラリセット

- Template: electron-vue 
- UI: ElementUI
- Frontend: Vue/Vuex
- Encode: fluent-ffmpeg
- Build System: WebPack
- UnitTest: karma(mocha/chai)
- その他
    - electron-json-storage
    - googleapis

# typescriptの注入

electron-vueは便利なんですが、typescriptは入っていないので

1. tsconfigの準備
2. ts-loaderを.electon-vue配下のwebpackビルドの対象に入れる
3. karma.confも修正する

## ユニットテスト

以下のような気持ちでどんどんテストを書いた。
Mixinに共通とかユーティル系を寄せたのでテストを書きやすかった。

```ts

import { expect, assert } from "chai";
import Vue from 'vue'
import Main from '../../../src/renderer/components/Main.vue'
import { Mixin } from '../../../src/renderer/mixin'
import { createLocalVue, shallowMount, mount } from '@vue/test-utils'

describe("Main Component and Mixins",  () => {

  // const wrapper = mount(Main)
  const localVue = createLocalVue()
  const wrapper = shallowMount(Main, {
    localVue,
    mixins: [Mixin]
  });

  it("validateJson: true", () => {
    expect(Mixin.methods.validateJson('{}')).to.be.true;
  })

  it("validateJson: false", () => {
    expect(Mixin.methods.validateJson('aaa')).to.be.false;
  })

  it("VueのshallowMountのテスト", () => {
    expect(wrapper.vm.$data.selectIndex === 1).to.be.true;
  })

})
```

# デバッグ

これは未だによくわかってない。

[釈然としないelectron-vueでのmainプロセスのデバッグ](https://qiita.com/jumbOrNot/items/9f0063c7636611799cb6)

# エンコード部分の連携

[electronでMain/Renderer間で通信するときに毎回作りたいクラス](https://qiita.com/jumbOrNot/items/9967080ee1eccc1aa374)に書きました。

ファイルアクセスが多かったり、goole driveへのリクエストを投げるし、Mainプロセス側でしかffmpegはもちろん動かないため、このようなクラスを作りました。


## Slack通知

slack通知は公式ライブラリで一瞬。

```ts

import { IncomingWebhook } from '@slack/webhook'

export default function postToSlack(message: string){

  let webhook = new IncomingWebhook('XXXXX')
  webhook.send({
    text: message
  })
}
```

## GoogleのtokenをOAuthで取得する

credentialsはgoogle api consoleから取得する。最初はfsでこれをコピーしていたが、electronではファイルアクセスやコピーをやろうとするとdevでビルドしたときとprodでビルドしたときでファイルアクセスが異なる。
例えば`src/main/json/sample.json`をfsで触ることはできない。jsonをtsでロードして、storageにコピーしている。`electron-json-storage`を使えば、Macであれば`~/Library/Application\ Support/{app name}/storage`にファイルが保存される。

```ts

const empty = require('is-empty')
const storage = require('electron-json-storage')
const { google } = require('googleapis')
const Url = require('url')
const queryString = require('querystring')
import { EventEmitter } from "events"
import * as path from 'path'
import storage from 'electron-json-storage'
import credentials from '../json/drive_credentials.json'

/**
 * Google Outh認証を実施する
 * 特定のユーザーに対して、Driveのアクセス可能なtokenの発行をアプリケーション内で実施
 */
export default class Auth extends EventEmitter{

  oAuth2Client: any
  browser: any
  scopes: any
  callbackUrl: any
  authenticated: any
  static KEY_TOKEN = 'KEY_TOKEN'

  constructor (_browser) {
    super()
    this.oAuth2Client = new google.auth.OAuth2(
      credentials.installed.client_id,
      credentials.installed.client_secret,
      credentials.installed.redirect_uris[0]
    )
    this.browser = _browser
    this.callbackUrl = path.join(__dirname, 'index.html')
    this.scopes = [
      'https://www.googleapis.com/auth/drive.metadata',
      'https://www.googleapis.com/auth/drive'
    ]
  }

  setCallbackUrl (url) {
    this.callbackUrl = url
  }

  async authenticate () {
    var token = await this.loadTokenFromCache()
    if (token == null){
      const code = await this.requestCodeViaBrowser()
      token = await this.requestToken(code)
      await this.saveToken(token)
    }
    return token
  }

  async saveToken(token) {
    return new Promise((resolve) => {
      storage.set(Auth.KEY_TOKEN, token, (err) => {
        resolve()
      })
    })
  }

  requestCodeViaBrowser(){
    return new Promise((resolve, reject) => {
      let url = this.oAuth2Client.generateAuthUrl({ access_type: 'offline', scope: this.scopes })
      this.browser.loadURL(url)
      this.browser.webContents.on('will-navigate', (_, newUrl) => {
        console.log('new url', newUrl)
        if (newUrl && newUrl.match('https://accounts.google.com/o/oauth2/approval/v2')){
          // OAuthでリダイレクトされたURLにtokenが含まれるので抽出する
          let query = queryString.parse(Url.parse(newUrl).query)
          let codeURL:string = query.response
          if (!codeURL) return reject('code url error')
          let code:string = query.response.split("&")[0].replace("code=","")
          if (!code) return reject(query.error)
          return resolve(code)
        }
      })
    })
  }

  // Googleのtoken情報を取得する
  async requestToken (code) {
    return new Promise((resolve, reject) => {
      this.oAuth2Client.getToken(code, (err, tokens) => {
        if (err) return reject(err)
        return resolve(tokens)
      })
    })
  }

  // tokenをキャッシュから取得
  async loadTokenFromCache () {
    return new Promise((resolve) => {
      storage.get(Auth.KEY_TOKEN, (err, data) => {
        if(!data.access_token) return resolve(null)
        resolve(data)
      })
    })
  }
}


```

## Google DriveへのAPIリクエスト

queryが結構ミソで、`mimeType = 'application/vnd.google-apps.folder' and trashed = false and parents = '${folderId}'`を渡すと削除されていない特定のフォルダ配下のフォルダ一覧が取れる。親子構造という概念があるんだけど1フォルダに同じ名前のファイルが置けたり、階層構造をいい感じに取得する方法はなさそう。
再帰的に取得する必要があり面倒だった。

```ts

  /**
   * Google Driveへの規定リクエスト
   * @param query Google Drive APIへのリクエスト
   */
  private async requestDrive(query: string): Promise<Array<any>>{
    return new Promise((resolve, reject) => {
      const auth = this.oAuth2Client
      const drive = google.drive({version: 'v3', auth});
      drive.files.list({
        // query properties: https://developers.google.com/drive/api/v3/search-parameters
        q: query,
        pageSize: 1000,
        // file properties: https://developers.google.com/drive/api/v3/reference/files
        fields: 'nextPageToken, files(id, name, parents, mimeType, owners, parents)',
      }, (err, res) => {
        if (err) return reject('The API returned an error: ' + err);
        const files = res.data.files;
        resolve(files)
      });
    })
  }
```

    
## 説明をmarkdownで書く

![スクリーンショット 2019-05-07 23.20.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31237/982d077a-5e3c-9576-e2f8-ec49d5740fa5.png)

こんな画面を作りたいときに説明をいちいちきれいに書くのが面倒なのでmarkdownで書きたかった。
[vue-markdown](https://github.com/miaolz123/vue-markdown)という手があります。
ちょっとした説明画面を作んないといけないときとかに楽だなと思いました。

```vue

<template>
  <div class="content">
    <VueMarkdown>
## 説明

エンコードは `fluent-ffmpeg` を使って実行されます。
ライブラリのアップデートで`libfdk-aac`は廃止されて`aac`になっていたなどある。
...

    </VueMarkdown>
  </div>
</template>

<script>
  import { Mixin } from '../mixin.ts'
  import VueMarkdown from 'vue-markdown'

  export default {
    mixins: [Mixin],
    components: {
      VueMarkdown
    },
    computed: {
    },
    data () {
      return {}
    },
    async mounted () {
    },
    methods: {
    }
  }
</script>
```

## アイコン作成

必要な`.icns`ファイルなどあり用意が地味に面倒。
[electron-icon-generator](https://www.npmjs.com/package/electron-icon-generator)を使えば1024*1024のpngがあればwin/mac用の必要なファイルが生成できる。

# 所感

[electron-vueのメインプロセスのデバッグはchrome://inspectを使う](https://github.com/SimulatedGREG/electron-vue/blob/master/docs/ja/faqs.md#how-can-i-debug-the-main-process)と書かれていたが普通にVSCodeでできた、普通にステップでバッグもできてとても便利でした。
またtypescriptのサポートをしていなかったので、気が向いたらプルリクを出しておきたいところです。
electron、だいぶ理解が進んでとても良かった。electron-vueを使ったのは正解だった。