📖

ElectronとNuxt.jsでテキストファイルの読み書きを行う

9 min read

あらすじ

Electronでテキストファイルの読み書きを行う方法を調べたのでNuxt.jsでテキストエディタ的なものをさっくり作れるかと思いきやそうでもなかった🤔

環境

electron 11.0.2
nuxt 2.14.7

前に調べたときよりもElectronのメジャーバージョンが上がっているが、ひとまずスルー。

Nuxt.jsのセットアップ

普通にWebページを作るようにプロジェクトを作成する。

npx create-nuxt-app sugarnote

create-nuxt-appのログ。特記事項はSPAモードを選択しているぐらい。

create-nuxt-app v2.14.0
✨  Generating Nuxt.js project in sugarnote
? Project name sugarnote
? Project description My majestic Nuxt.js project
? Author name sato_tcs
? Choose programming language JavaScript
? Choose the package manager Yarn
? Choose UI framework Tailwind CSS
? Choose custom server framework None (Recommended)
? Choose Nuxt.js modules (Press <space> to select, <a> to toggle al
l, <i> to invert selection)
? Choose linting tools Prettier
? Choose test framework None
? Choose rendering mode Single Page App
? Choose development tools (Press <space> to select, <a> to toggle
all, <i> to invert selection)

コマンド実行後に画面に表示された方法でNuxt.jsのプロジェクトを実行する。

cd sugarnote
yarn dev

SPAモード選択の理由

productionとして実行するときにサーバを動かしたくなかったためSSRモードは除外。残りはSPAモードとSSGモードになるが、URLからパラメータを取得するとしたらSPAの方が都合が良さそうなのでそちらに(paramsじゃなくてqueryで済ますならSSGでも良い?🤔)。

Electronからページを表示

ElectronでNuxt.jsのページを見れるようにする。

メインプロセスのファイル

ElectronとExpressを追加する(Expressは開発中にメインプロセスからNuxt.jsのプロジェクトを動かすためのもの)。

yarn add --dev electron express

main.jsを作成する。

main.js(全コード)
const { loadNuxt, build } = require('nuxt')
const { app: electronApp, BrowserWindow } = require('electron')
const path = require('path')

const expressApp = require('express')()
const isDev = process.env.NODE_ENV !== 'production'
const port = process.env.PORT || 3000

function createWindow() {
  const win = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      nodeIntegration: true
    }
  })

  const url = isDev
    ? `http://localhost:${port}/`
    : `file://${path.resolve(__dirname, '..', 'dist', 'index.html')}`

  win.loadURL(url)
  win.webContents.openDevTools()
}

async function start() {
  if (isDev) {
    const nuxt = await loadNuxt(isDev ? 'dev' : 'start')
    expressApp.use(nuxt.render)
    build(nuxt)
    expressApp.listen(port, '0.0.0.0')
    console.log(`Server listening on \`localhost:${port}\``)
  }

  electronApp.whenReady().then(createWindow)

  electronApp.on('window-all-closed', () => {
    if (process.platform !== 'darwin') {
      electronApp.quit()
    }
  })

  electronApp.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow()
    }
  })
}

start()

メインプロセスからNuxt.jsを実行する

開発中はlocalhostで動いているものを参照し、productionではビルド後のファイルを参照する。

developmentのときNuxt.jsを動かす

if (isDev) {
  const nuxt = await loadNuxt(isDev ? 'dev' : 'start')
  expressApp.use(nuxt.render)
  build(nuxt)
  expressApp.listen(port, '0.0.0.0')
  console.log(`Server listening on \`localhost:${port}\``)
}

Electronから参照するURL

const url = isDev
  ? `http://localhost:${port}/`
  : `file://${path.resolve(__dirname, '..', 'dist', 'index.html')}`

Nuxt.jsの設定ファイル

nuxt.config.jsを編集する。

nuxt.config.js(全コード)
export default {
  mode: 'spa',
  /*
   ** Headers of the page
   */
  head: {
    title: process.env.npm_package_name || '',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      {
        hid: 'description',
        name: 'description',
        content: process.env.npm_package_description || ''
      }
    ],
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }]
  },
  /*
   ** Customize the progress-bar color
   */
  loading: { color: '#fff' },
  /*
   ** Global CSS
   */
  css: [],
  /*
   ** Plugins to load before mounting the App
   */
  plugins: [],
  /*
   ** Nuxt.js dev-modules
   */
  buildModules: [
    // Doc: https://github.com/nuxt-community/nuxt-tailwindcss
    '@nuxtjs/tailwindcss'
  ],
  /*
   ** Nuxt.js modules
   */
  modules: [],
  /*
   ** Build configuration
   */
  build: {
    /*
     ** You can extend webpack config here
     */
    extend(config, ctx) {
      config.output.publicPath = '_nuxt/'
    }
  },
  /*
   **
   */
  router: {
    mode: 'hash'
  }
}

routerの設定

routerのmodeを指定しないと、おそらくhistoryモードになっている。今回はURLを見せにいかない想定なのでhashモードでSPAを動かす。

hashモードにせず「どのアクセスからでもpages/index.vueを表示する」といった方法でも動かせそう🤔(参考:nuxtでgenerateしたファイルをローカルで実行する。 - Qiita

export default {
  // ...
  router: {
    mode: 'hash'
  }
}

publicPathの設定

file://からソースを参照したときに絶対パスだとファイルが見つからなそうなので修正する。

export default {
  // ...
  build: {
    extend(config, ctx) {
      config.output.publicPath = '_nuxt/'
    }
  }
}

本来の用途としては、CDNを使用するときに書き換えるらしい🙄(参考

動作確認

開発用のコマンド(ポート番号はあってもなくても可)。

env PORT=3333 yarn electron .

本番環境では、ビルドを行ってから環境を指定してelectron .を実行する。

yarn build
env NODE_ENV=production yarn electron .

ファイルの読み書きの実装

前に調べたものを参考にファイルの読み書きを実装してみたところ、Electronの画面上にエラーが表示された。

ERROR in ./node_modules/electron/index.js
Module not found: Error: Can't resolve 'fs' in '/Users/hogehoge/sugarnote/node_modules/electron'

「まあそこ参照してたら見つからないよなー🤔」という内容のエラーが出る。

Webページを検索してみたところ、「fsがない」というエラーはWebpackの使用中に割と起こる模様。原因はWebpackで行うコンパイルがデフォルトでwebをターゲットにしているためであるためらしい(webがターゲットだと、nodeの機能であるfsモジュールが使えない)。

検索で見つかる対処法が直感的に理解できなかったので(emptyとか付けるやつ)、さらに調べるとcontextBridgeについて書かれている記事を見つけた。

https://qiita.com/pochman/items/64b34e9827866664d436
https://qiita.com/hibara/items/c59fb6924610fc22a9db

ざっくりいうと、

  • レンダラープロセスではセキュリティ上の理由でnodeモジュールを使用しない方がよい
  • レンダラープロセスでnodeモジュールを使用したい場合はレンダラープロセスからメインプロセスに要求して、メインプロセスから結果を返してもらう(レンダラープロセスでrequire('electron').remoteを使用しない)

といった感じになる。

元々フロント側ででfsを読み込んでいるのに違和感があったので修正を始める。

main.js(抜粋)

const win = new BrowserWindow({
  width: 800,
  height: 600,
  webPreferences: {
    nodeIntegration: false,
    contextIsolation: true,
    preload: path.resolve(__dirname, 'preload.js')
  }
})

preload.js

const { contextBridge, ipcRenderer } = require('electron')

contextBridge.exposeInMainWorld('electron', {
  open: () => ipcRenderer.invoke('open'),
  save: data => ipcRenderer.invoke('save', data)
})

pages/index.js

素のJavaScriptからVue.jsに書き換えた。

pages/index.js(全コード)
<template>
  <div>
    <div>
      <button type="button" @click="onOpen">Open</button>
      <button type="button" @click="onSave">Save</button>
    </div>
    <div>
      <textarea v-model="text"></textarea>
    </div>
  </div>
</template>

<script>
export default {
  data: () => ({
    text: ''
  }),
  mounted() {
  },
  methods: {
    async onOpen(event) {
      const { canceled, data } = await electron.open()
      if (canceled) return
      this.text = data[0] || ''
    },
    async onSave(event) {
      await electron.save(this.text)
    }
  }
}
</script>

<style scoped>
button {
  @apply px-2 rounded bg-blue-600 text-white;
}

textarea {
  @apply border border-gray-400 bg-white;
}
</style>

参考

あとがき

fs見つからない問題と遭遇してから開発を優先したため、「あーこれどういう流れで解決したっけ?🤔」と思い出せなくて雰囲気で書いたので調査の過程が実際と若干異なるかも知れない🙄

Discussion

ログインするとコメントできます