心地よい春の季節となりました、一方で私にとっては毎年悩まされる花粉症の時期でもあります。人一倍症状が強いので、きつめの薬を飲んで耐え忍んでいる今日この頃です。しかし、今年はかなりキツめな気がしてます、、一日中鼻がむずむずし、鼻水も止まりません、ティッシュがすぐに尽きてしまう。。なお、環境省からの2019年の全国疫学調査レポートによると、スギの花粉症有病率が10代〜50代で45%以上、60代で30%程度とあり(参考リンク)、日本の労働者の3割以上がこの時期に花粉症で思考が鈍ってしまったり薬の作用でぼーっとしたりしているのかと思うと経済への影響も無視できないなという気がしています。(ただこれを機会と見ると大きな市場といえるかも。日本の人口推計データの参考:10〜69歳は8,000万人 ー人口推計 – 2023年(令和5年) 3 月 報 -)
さて、今回はWeb技術でデスクトップアプリケーションを作成できるフレームワークであるElectronでnuxt3を使った簡単なアプリケーションを作ってみたいと思います。Nuxt3の開発のしやすさをそのままElectronでの開発で活かせるのでいい感じです。本記事でのゴールは、EC2のインスタンスを起動及び停止する簡単なアプリケーションとしたいと思います。
ゴールのイメージ
EC2のインスタンスIDを設定して、インスタンスの起動や停止、状態を取得するだけの簡単なアプリケーションです。「開発目的等で頻繁に利用するインスタンスをAWSコンソールにログインすることなくGUIでサクッと操作できる」というところがベネフィットになります。
環境
- macOS Catalina (intel cpu)
- nuxt 3.3.2
- vuetify 3.1.8
- electron-builder 23.6.0 os19.6.0
- Node.js 16.19.1
nuxtプロジェクトの作成
1 2 3 |
npx nuxi init nuxt-electron-sample cd nuxt-electron-sample yarn |
ここでは、プロジェクトトップの見通しを良くするため、nuxtのclientディレクトリ 一式をsrcディレクトリ下に移動することとします。
1 2 |
mkdir src components layouts pages plugins mv app.vue public components layouts pages plugins src/ |
nuxt.config.tsでsrcの場所を指定します。
1 2 3 |
export default defineNuxtConfig({ srcDir: 'src/', }) |
nuxt-electronのセットアップ
nuxtのelectronモジュールを使用します。以下のとおりnuxt-electronをインストールします。
1 |
yarn add --dev nuxt-electron vite-electron-plugin vite-plugin-electron-renderer electron electron-builder |
続いてnuxt.config.ts
でモジュールのロードを指定します。
nuxt.config.ts
1 2 3 4 5 6 |
export default defineNuxtConfig({ srcDir: 'src/', modules: [ 'nuxt-electron', ], }) |
次にelectron/main.ts
というファイルを作成します。今回は、nuxt-electronのexampleソースを参考にします。ライセンスも併記しておきます。
electron/main.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// 参考ソース // https://github.com/caoxiemeihao/nuxt-electron/blob/main/examples/quick-start/electron/main.ts // 参考元ソースのライセンス // https://github.com/caoxiemeihao/nuxt-electron/blob/main/LICENSE import { app, BrowserWindow } from 'electron' import path from 'node:path' process.env.ROOT = path.join(__dirname, '..') process.env.DIST = path.join(process.env.ROOT, 'dist-electron') process.env.VITE_PUBLIC = process.env.VITE_DEV_SERVER_URL ? path.join(process.env.ROOT, 'src/public') : path.join(process.env.ROOT, '.output/public') process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' let win: BrowserWindow const preload = path.join(process.env.DIST, 'preload.js') function bootstrap() { win = new BrowserWindow({ webPreferences: { preload, }, }) if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL) win.webContents.openDevTools() } else { win.loadFile(path.join(process.env.VITE_PUBLIC!, 'index.html')) } } app.whenReady().then(bootstrap) |
package.json
最後にpackage.jsonにエントリーポイントを追加します。
1 2 3 4 |
{ + "version":"0.0.1", + "main": "dist-electron/main.js" } |
veutify3のセットアップ
以下のページを参考にvuetify3をセットアップします。
1 |
yarn add -D vuetify@next sass vite-plugin-vuetify @mdi/js |
今回のセットアップ後のソースは以下のようになります。
src/plugins/vuetify.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { createVuetify } from 'vuetify' import { aliases, mdi } from 'vuetify/iconsets/mdi-svg' export default defineNuxtPlugin((nuxtApp) => { const vuetify = createVuetify({ icons: { defaultSet: 'mdi', aliases, sets: { mdi, }, }, theme: { defaultTheme: 'dark', }, }) nuxtApp.vueApp.use(vuetify) }) |
nuxt.config.ts
vuetifyのcssスタイル読み込み等を設定します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import vuetify from "vite-plugin-vuetify"; export default defineNuxtConfig({ srcDir: "src/", css: ["@/assets/css/main.scss"], modules: ["nuxt-electron"], build: { transpile: ["vuetify"], }, hooks: { "vite:extendConfig": (config) => { config.plugins!.push(vuetify()); }, }, vite: { ssr: { noExternal: ["vuetify"], }, define: { "process.env.DEBUG": false, }, }, }); |
src/assets/css/main.scss
1 |
@use 'vuetify/styles'; |
プリロードスクリプト
electronでは、レンダラープロセスというコンテキストでプログラムが実行されます。そして、このコンテキストではプログラムの安全性確保のため通常Node.jsのAPIにアクセスしたりできないようサンドボックス化されています。そこで、レンダラープロセスからそのような特権APIを呼び出すためにプリロードという仕組みが用意されています。プリロード経由でレンダラープロセスにAPIを公開することで、安全に特権APIを呼ベるようになります。
今回はawsのプロファイルを読み書きをするための機能を実装する目的で以下のようにプリロードスクリプトを作成します。
electron/preload.ts
window.api
というネームスペースでレンダラープロセスからAPIを呼ベるようにします。
1 2 3 4 5 6 7 8 9 10 11 |
import { contextBridge, ipcRenderer } from 'electron' contextBridge.exposeInMainWorld('api', { writeconfig: async (data: AwsConfig) => { return await ipcRenderer.invoke('writeconfig', data) }, readconfig: async () => { return await ipcRenderer.invoke('readconfig') }, }) |
src/@types/global.d.ts
コンポーネントからwindow.api.xxxx
という指定で呼べるようにtypeを定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
interface Window { api: Api } interface Api { readconfig: () => AwsConfig writeconfig: (config: AwsConfig) => void } interface AwsConfig { region: string credentials: { accessKeyId: string secretAccessKey: string } instanceId?: string } |
electron/main.ts
レンダラープロセスからAPIを呼び出せるように実体を定義します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
// 参考ソース // https://github.com/caoxiemeihao/nuxt-electron/blob/main/examples/quick-start/electron/main.ts // 参考元ソースのライセンス // https://github.com/caoxiemeihao/nuxt-electron/blob/main/LICENSE import { app, BrowserWindow, ipcMain } from 'electron' import path from 'node:path' import fs from 'node:fs' process.env.ROOT = path.join(__dirname, '..') process.env.DIST = path.join(process.env.ROOT, 'dist-electron') process.env.VITE_PUBLIC = process.env.VITE_DEV_SERVER_URL ? path.join(process.env.ROOT, 'src/public') : path.join(process.env.ROOT, '.output/public') process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true' let win: BrowserWindow const preload = path.join(process.env.DIST, 'preload.js') function bootstrap() { win = new BrowserWindow({ webPreferences: { preload, }, }) // $HOME/aws-config.jsonにプロファイルを設定 const userHome = process.env[process.platform == 'win32' ? 'USERPROFILE' : 'HOME'] const configFile = path.join(userHome!, 'aws-config.json') // レンダラープロセスからのメッセージを処理する ipcMain.handle('writeconfig', async (event, data) => { try { fs.writeFileSync(configFile, JSON.stringify(data, null, '\t')) return configFile } catch (error) { return false } }) ipcMain.handle('readconfig', () => { let config if (fs.existsSync(configFile)) { const data = fs.readFileSync(configFile, 'utf-8') if (data) { config = JSON.parse(data) } } return config }) if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL) win.webContents.openDevTools() } else { win.loadFile(path.join(process.env.VITE_PUBLIC!, 'index.html')) } } app.whenReady().then(bootstrap) |
参考 https://www.electronjs.org/ja/docs/latest/tutorial/tutorial-preload
アプリケーションの作成
aws-sdkのv3をインストールします。v3では、ソースがTypeScriptベースで再構築されており、またクライアントがサービスごとに独立したモジュール構造になっているようです。
aws-sdk v3のインストール (EC2クライアント)
今回は、EC2クライアントを使用するため以下のようにしてクライアントをインストールします。
1 |
yarn add -D @aws-sdk/client-ec2 |
コンポーネントの作成
src/app.vue
1 2 3 4 5 6 7 8 |
<template> <div class="wrapper"> <NuxtLayout> <NuxtLoadingIndicator /> <NuxtPage /> </NuxtLayout> </div> </template> |
src/layouts/default.vue
1 2 3 4 5 |
<template> <div class="app"> <slot></slot> </div> </template> |
src/pages/index.vue
ただコンポーネントを表示するだけとします。
1 2 3 |
<template> <AwsEc2ControlPanel></AwsEc2ControlPanel> </template> |
src/components/AwsEc2ControlPanel.vue
長いですがやっていることは、awsのec2の起動・停止・情報取得及び設定ファイルの読み書きです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 |
<script lang="ts" setup> import { EC2Client, StartInstancesCommand, StopInstancesCommand, DescribeInstancesCommand } from '@aws-sdk/client-ec2' import { mdiEyeOff, mdiEye } from '@mdi/js' const ec2Client = shallowRef<EC2Client>() const awsConfig = ref<AwsConfig>() const config = reactive<{ data: AwsConfig }>({ data: { region: '', credentials: { accessKeyId: '', secretAccessKey: '', }, instanceId: '', }, }) const accessKeyShow = ref(false) const secretAccessKeyShow = ref(false) const loadingStart = ref(false) const loadingStop = ref(false) const loadingRefresh = ref(false) const loadingDescribe = ref(false) const error = ref<undefined | null | Error>() const message = ref() const alert = ref(false) const instanceId = ref('') const instanceInfo = ref<{ name: string; value: any }[]>([]) const instanceState = ref() const instanceDialog = ref(false) const configDialog = ref(false) const rules = [ (value: string) => !!value || '必須', (value: string) => { const pattern = /^i-[0-9a-zA-Z]+$/ return pattern.test(value) || '不正なインスタンスID' }, ] const configRules = [(value: string) => !!value || '必須'] const state = computed(() => { const elm = instanceInfo.value.find((elm) => elm.name === '状態') if (elm) { return elm.value } return '不明' }) const withAsyncState = async (exec: () => Promise<void>) => { clearMessage() try { await exec() } catch (err) { error.value = err as unknown as Error message.value = error.value.message throw err } } const execStartInstance = () => { const command = new StartInstancesCommand({ InstanceIds: [instanceId.value] }) return ec2Client.value!.send(command) } const execStopInstance = () => { const command = new StopInstancesCommand({ InstanceIds: [instanceId.value] }) return ec2Client.value!.send(command) } const execDescribeInstance = () => { const command = new DescribeInstancesCommand({ InstanceIds: [instanceId.value] }) return ec2Client.value!.send(command).then((response) => { if (response.Reservations && response.Reservations[0].Instances) { const instance = response.Reservations[0].Instances[0] instanceInfo.value = [ { name: 'イメージ', value: instance.ImageId }, { name: 'タイプ', value: instance.InstanceType }, { name: '起動時刻', value: instance.LaunchTime }, { name: 'プライベートアドレス', value: instance.PrivateIpAddress, }, { name: 'プライベートDNS', value: instance.PrivateDnsName }, { name: 'パブリックアドレス', value: instance.PublicDnsName }, { name: 'パブリックDNS', value: instance.PublicIpAddress }, { name: 'サブネットID', value: instance.SubnetId }, { name: '状態', value: instance.State?.Name }, { name: 'VpcID', value: instance.VpcId }, { name: 'タグ', value: JSON.stringify(instance.Tags) }, ] instanceState.value = instance.State?.Name return response } }) } watch(configDialog, () => { clearMessage() }) onMounted(async () => { await reloadConfig() if (instanceId.value) { execDescribeInstance() } }) function clearMessage() { alert.value = false message.value = '' error.value = null } async function readAwsConfig() { return await window.api.readconfig() } async function startInstance() { await withAsyncState(async () => { loadingStart.value = true await execStartInstance().then((response) => { if (response.StartingInstances) { const { CurrentState, PreviousState } = response.StartingInstances[0] message.value = `現在の状態: ${CurrentState!.Name}, 以前の状態: ${PreviousState!.Name}` instanceState.value = CurrentState!.Name } }) alert.value = true loadingStart.value = false }) } async function stopInstance() { await withAsyncState(async () => { loadingStop.value = true await execStopInstance().then((response) => { if (response.StoppingInstances) { const { CurrentState, PreviousState } = response.StoppingInstances[0] message.value = `現在の状態: ${CurrentState!.Name}, 以前の状態: ${PreviousState!.Name}` instanceState.value = CurrentState!.Name } }) alert.value = true loadingStop.value = false }) } async function refresh() { await withAsyncState(async () => { loadingRefresh.value = true await execDescribeInstance() loadingRefresh.value = false }) } async function describeInstances() { await withAsyncState(async () => { loadingDescribe.value = true await execDescribeInstance() .then(() => { instanceDialog.value = true instanceState.value = state.value }) .catch((err) => (alert.value = true)) loadingDescribe.value = false }) } async function reloadConfig() { await readAwsConfig().then((data) => { if (data) { awsConfig.value = data config.data = awsConfig.value ec2Client.value = new EC2Client({ region: awsConfig.value.region, credentials: awsConfig.value.credentials, }) instanceId.value = config.data.instanceId || '' } }) } async function saveConfig() { await withAsyncState(async () => { await window.api.writeconfig({ region: config.data.region, credentials: { accessKeyId: config.data.credentials.accessKeyId, secretAccessKey: config.data.credentials.secretAccessKey, }, instanceId: config.data.instanceId, }) }) .then(async () => { message.value = `書き込みに成功しました` }) .catch(() => { message.value = '書き込みに失敗しました' }) await reloadConfig() } </script> <template> <v-container> <v-card class="mx-auto pa-5" max-width="700"> <h1 class="mx-auto text-h6 text-center font-weight-bold py-5">AWS EC2 コントロールパネル</h1> <v-alert v-model="alert" :type="error ? 'error' : 'success'" closable> {{ message }} </v-alert> <div class="my-2"> インスタンスの状態:<v-chip v-if="instanceState"> {{ instanceState }} </v-chip> </div> <v-form class="my-5"> <v-text-field v-model="instanceId" label="インスタンスID(例:i-xxxx)" filled :rules="rules"></v-text-field> <div class="text-center"> <v-btn color="primary" class="ma-3" variant="tonal" large :loading="loadingStart" @click="startInstance" :disabled="!instanceId" > 起動 </v-btn> <v-btn color="error" class="ma-3" variant="tonal" large :loading="loadingStop" @click="stopInstance" :disabled="!instanceId" > 停止 </v-btn> <v-btn class="ma-3" large variant="tonal" @click="refresh" :loading="loadingRefresh" :disabled="!instanceId"> 更新 </v-btn> <v-btn class="ma-3" large variant="tonal" @click="describeInstances" :loading="loadingDescribe" :disabled="!instanceId" > インスタンス情報 </v-btn> <v-btn color="success" class="ma-3" large variant="tonal" @click="configDialog = true"> 設定編集 </v-btn> </div> </v-form> <v-dialog v-if="instanceDialog" v-model="instanceDialog" min-width="500" max-width="1024"> <v-card> <v-card-title class="text-h6"> インスタンス:{{ instanceId }} </v-card-title> <v-table dense> <template v-slot:default> <thead> <tr> <th class="wtext-left">項目</th> <th class="text-left">内容</th> </tr> </thead> <tbody> <tr v-for="item in instanceInfo" :key="item.name" class="text-body-2"> <td>{{ item.name }}</td> <td>{{ item.value }}</td> </tr> </tbody> </template> </v-table> <v-card-actions> <v-spacer></v-spacer> <v-btn color="primary" text @click="instanceDialog = false"> 閉じる </v-btn> </v-card-actions> </v-card> </v-dialog> <v-dialog v-if="configDialog" v-model="configDialog" class="pa-4" min-width="500" z-index="5"> <v-card class="pa-5"> <v-card-title>AWS設定</v-card-title> <v-text-field v-model="config.data.region" label="リージョン" filled :rules="configRules"></v-text-field> <v-text-field v-model="config.data.credentials.accessKeyId" label="アクセスキー" :type="accessKeyShow ? 'text' : 'password'" :append-icon="accessKeyShow ? mdiEye : mdiEyeOff" filled :rules="configRules" @click:append="accessKeyShow = !accessKeyShow" ></v-text-field> <v-text-field v-model="config.data.credentials.secretAccessKey" label="シークレットキー" :type="secretAccessKeyShow ? 'text' : 'password'" :append-icon="secretAccessKeyShow ? mdiEye : mdiEyeOff" filled :rules="configRules" @click:append="secretAccessKeyShow = !secretAccessKeyShow" > </v-text-field> <v-text-field v-model="config.data.instanceId" label="デフォルトインスタンス" filled></v-text-field> <v-card-actions> <span v-if="message" class="text-subtitle-2">{{ message }}</span> <v-spacer></v-spacer> <v-btn color="primary" text @click=";(configDialog = false), (message = '')"> 閉じる </v-btn> <v-btn color="primary" variant="tonal" @click="saveConfig"> 保存する </v-btn> </v-card-actions> </v-card> </v-dialog> </v-card> </v-container> </template> |
aws-sdk v3のクライアントは、以下のようにクライアントオブジェクトの初期化時にregion
やcredentials
を渡すようなインターフェースになっています。また、起動や停止など呼び出すコマンドに対して各コマンド用のクラスが用意されており、コマンド実行はコマンドオブジェクトを作成してクライアントのsendメソッドに渡すことで実行されるような形になっています。詳細は、公式のドキュメントを参考にしてください。
1 2 3 4 5 6 7 8 9 |
const ec2Client = new EC2Client({ region: "ap-northeast-1", credentials: { accessKeyId: "xxxx", secretAccessKey: "xxxx" }, }) const command = new StartInstancesCommand({ InstanceIds: ["i-xxxx"] }) const response = await ec2Client.value!.send(command) |
参考 https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-ec2/
ビルド+実行
package.jsonを以下のように修正しておきます。
package.json
1 2 3 4 5 6 7 8 9 10 11 |
{ ...省略 "scripts": { "build": "nuxi build --prerender && electron-builder", "dev": "nuxt dev", "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare", }, ...省略 } |
以下のコマンドで実行します。
1 |
yarn build |
ビルドに成功すると、releaseの下にアプリケーションバイナリが生成されます。
アプリケーションをクリックしてみましょう、以下のようなアプリケーションの画面が表示されるはず。
設定編集画面で設定を入力して保存します。
インスタンス情報を取得してみます。
上手く取得できているようです!
おわりに
nuxt3とelectronの組み合わせでAWSのEC2を操作するだけの簡単なデスクトップアプリケーションを作成してみました。electronの作法については別途学ぶ必要がありますが、nuxt3の開発の良さをそのままelectronで活かせるのが嬉しいですね。最近は、electron以外にOSのwebブラウザを利用するものであったり、Node.jsでなくdenoを使うものであったりと色々と進化しているようです。VSCodeやSlackアプリなどもelectronで作成されており、しっかりしたアプリケーションを作成する候補としてelectronを学んでおくのは良いのではないでしょうか。
今回のサンプル
githubにアップロードしておきました。
その他
誤記や間違いがあれば教えてください。
参考リンク
- https://nuxt.com/modules/electron
- https://github.com/caoxiemeihao/nuxt-electron/blob/main/examples/quick-start/
- https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-ec2/
コメント