副業で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は入っていないので
- tsconfigの準備
- ts-loaderを.electon-vue配下のwebpackビルドの対象に入れる
- karma.confも修正する
ユニットテスト
以下のような気持ちでどんどんテストを書いた。
Mixinに共通とかユーティル系を寄せたのでテストを書きやすかった。
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プロセスのデバッグ
エンコード部分の連携
electronでMain/Renderer間で通信するときに毎回作りたいクラスに書きました。
ファイルアクセスが多かったり、goole driveへのリクエストを投げるし、Mainプロセス側でしかffmpegはもちろん動かないため、このようなクラスを作りました。
Slack通知
slack通知は公式ライブラリで一瞬。
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
にファイルが保存される。
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フォルダに同じ名前のファイルが置けたり、階層構造をいい感じに取得する方法はなさそう。
再帰的に取得する必要があり面倒だった。
/**
* 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で書く
こんな画面を作りたいときに説明をいちいちきれいに書くのが面倒なのでmarkdownで書きたかった。
vue-markdownという手があります。
ちょっとした説明画面を作んないといけないときとかに楽だなと思いました。
<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を使えば1024*1024のpngがあればwin/mac用の必要なファイルが生成できる。
所感
electron-vueのメインプロセスのデバッグはchrome://inspectを使うと書かれていたが普通にVSCodeでできた、普通にステップでバッグもできてとても便利でした。
またtypescriptのサポートをしていなかったので、気が向いたらプルリクを出しておきたいところです。
electron、だいぶ理解が進んでとても良かった。electron-vueを使ったのは正解だった。