心地よい春の季節となりました、一方で私にとっては毎年悩まされる花粉症の時期でもあります。人一倍症状が強いので、きつめの薬を飲んで耐え忍んでいる今日この頃です。しかし、今年はかなりキツめな気がしてます、、一日中鼻がむずむずし、鼻水も止まりません、ティッシュがすぐに尽きてしまう。。なお、環境省からの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プロジェクトの作成
npx nuxi init nuxt-electron-sample cd nuxt-electron-sample yarn
ここでは、プロジェクトトップの見通しを良くするため、nuxtのclientディレクトリ 一式をsrcディレクトリ下に移動することとします。
mkdir src components layouts pages plugins mv app.vue public components layouts pages plugins src/
nuxt.config.tsでsrcの場所を指定します。
export default defineNuxtConfig({
srcDir: 'src/',
})
nuxt-electronのセットアップ
nuxtのelectronモジュールを使用します。以下のとおりnuxt-electronをインストールします。
yarn add --dev nuxt-electron vite-electron-plugin vite-plugin-electron-renderer electron electron-builder
続いてnuxt.config.tsでモジュールのロードを指定します。
nuxt.config.ts
export default defineNuxtConfig({
srcDir: 'src/',
modules: [
'nuxt-electron',
],
})
次にelectron/main.tsというファイルを作成します。今回は、nuxt-electronのexampleソースを参考にします。ライセンスも併記しておきます。
electron/main.ts
// 参考ソース
// 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にエントリーポイントを追加します。
{
+ "version":"0.0.1",
+ "main": "dist-electron/main.js"
}
veutify3のセットアップ
以下のページを参考にvuetify3をセットアップします。

yarn add -D vuetify@next sass vite-plugin-vuetify @mdi/js
今回のセットアップ後のソースは以下のようになります。
src/plugins/vuetify.ts
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スタイル読み込み等を設定します。
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
@use 'vuetify/styles';
プリロードスクリプト
electronでは、レンダラープロセスというコンテキストでプログラムが実行されます。そして、このコンテキストではプログラムの安全性確保のため通常Node.jsのAPIにアクセスしたりできないようサンドボックス化されています。そこで、レンダラープロセスからそのような特権APIを呼び出すためにプリロードという仕組みが用意されています。プリロード経由でレンダラープロセスにAPIを公開することで、安全に特権APIを呼ベるようになります。
今回はawsのプロファイルを読み書きをするための機能を実装する目的で以下のようにプリロードスクリプトを作成します。
electron/preload.ts
window.apiというネームスペースでレンダラープロセスからAPIを呼ベるようにします。
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を定義します。
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を呼び出せるように実体を定義します。
// 参考ソース
// 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クライアントを使用するため以下のようにしてクライアントをインストールします。
yarn add -D @aws-sdk/client-ec2
コンポーネントの作成
src/app.vue
<template>
<div class="wrapper">
<NuxtLayout>
<NuxtLoadingIndicator />
<NuxtPage />
</NuxtLayout>
</div>
</template>
src/layouts/default.vue
<template>
<div class="app">
<slot></slot>
</div>
</template>
src/pages/index.vue
ただコンポーネントを表示するだけとします。
<template> <AwsEc2ControlPanel></AwsEc2ControlPanel> </template>
src/components/AwsEc2ControlPanel.vue
長いですがやっていることは、awsのec2の起動・停止・情報取得及び設定ファイルの読み書きです。
<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メソッドに渡すことで実行されるような形になっています。詳細は、公式のドキュメントを参考にしてください。
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
{
...省略
"scripts": {
"build": "nuxi build --prerender && electron-builder",
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
},
...省略
}
以下のコマンドで実行します。
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/


コメント