Nuxt3+Electronで簡易なアプリを作成してみる

photo of woman writing on tablet computer while using laptop NuxtJS
Photo by Antoni Shkraba on Pexels.com
この記事は約31分で読めます。

心地よい春の季節となりました、一方で私にとっては毎年悩まされる花粉症の時期でもあります。人一倍症状が強いので、きつめの薬を飲んで耐え忍んでいる今日この頃です。しかし、今年はかなりキツめな気がしてます、、一日中鼻がむずむずし、鼻水も止まりません、ティッシュがすぐに尽きてしまう。。なお、環境省からの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をセットアップします。

Nuxt 3 で Vuetify 3 を使う
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のクライアントは、以下のようにクライアントオブジェクトの初期化時にregioncredentialsを渡すようなインターフェースになっています。また、起動や停止など呼び出すコマンドに対して各コマンド用のクラスが用意されており、コマンド実行はコマンドオブジェクトを作成してクライアントの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にアップロードしておきました。

GitHub - moritetu/nuxt3-electron-template: nuxt3 electron template with vuetify
nuxt3 electron template with vuetify. Contribute to moritetu/nuxt3-electron-template development by creating an account ...

その他

誤記や間違いがあれば教えてください。

参考リンク

コメント

タイトルとURLをコピーしました