ローカルアプリとして実装する試み。

ブツ

やはりDEMOだとIPC通信のところでエラーになってしまう。

Uncaught (in promise) TypeError: Cannot read property 'loadDb' of undefined
    at renderer.js:3

インストール&実行

このようにローカルで実行すれば成功する。

NAME='Electron.sql.js.MyLog.20220807142552'
git clone https://github.com/ytyaru/$NAME
cd $NAME
npm install
npm start

eyecatch.png

以前

WEB版で作ったやつ。

WEB版はセキュリティのこともあってローカルファイルを操作するのに色々と制限があった。そこでまずはIndexedDBで保存した。けれどこれはブラウザが特定ドメインごとにデータをもつ仕様であり、共有したいのにできないことが多々ある。また、環境により異なるファイルサイズ上限などもある。

sql.jsによってSQLite3のファイルを作成した。これでローカルでもsqlite3コマンドを使ってつぶやきの内容を作成・削除できる。けれどやはりデータの共有には色々と制限があった。FileSystemAccessAPIを使えばローカルファイルを読み書きできるが、使えるブラウザが限られており、毎回確認ダイアログに答えねばならず、常用アプリとしては使いづらい。

さらに以下で学習したElectronやNode.jsの機能をあわせた。

今回

WEB版をベースにした。

Electronでローカルファイル操作を自由にできるようにした。確認ダイアログの操作も不要なので自動化できるはず。ただしファイル操作周りのコードをすべて書き換えねばならず大変。

また、ブラウザ拡張機能が使えないためmpurse APIが使えない。もし仮にElectronやNode.jsのAPIでブラウザ拡張を使う方法があったとしてもmpurse APIはHTTPSサーバ上でないと動作しない。おそらくfetch APIやClipboard APIの制約だと思われる。なのでローカルでは動作しない。おそらく代替方法はあるはずだが未調査。

コード抜粋

package.json

{
  "name": "mylog-electron",
  "version": "1.0.0",
  "description": "",
  "main": "src/js/main.js",
  "scripts": {
    "start": "electron .",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "electron": "^20.0.1"
  },
  "dependencies": {
    "sql.js": "^1.7.0"
  }
}

main.js

const { app, BrowserWindow, ipcMain, dialog } = require('electron')
const path = require('path')
const fs = require('fs')
const initSqlJs = require('sql.js');
const lib = new Map()

function createWindow () {
    const mainWindow = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            nodeIntegration: false,
            enableRemoteModule: true,
            contextIsolation: true,
            preload: path.join(__dirname, 'preload.js')
        }
    })
    mainWindow.loadFile('index.html')
    mainWindow.webContents.openDevTools()
}
app.whenReady().then(() => {
    createWindow()
    app.on('activate', function () {
        if (BrowserWindow.getAllWindows().length === 0) createWindow()
    })
})
app.on('window-all-closed', function () {
    if (process.platform !== 'darwin') app.quit()
})

async function loadDb(filePath=`src/db/mylog.db`) {
    if (null === filePath) { filePath = `src/db/mylog.db` }
    if (!lib.has(`DB`)) {
        const SQL = await initSqlJs().catch(e=>console.error(e))
        lib.set(`SQL`, SQL)
        const db = new SQL.Database(new Uint8Array(fs.readFileSync(filePath)))
        lib.set(`DB`, db)
    }
    return lib.get(`DB`)
}
ipcMain.handle('loadDb', async(event, filePath=null) => {
    return loadDb(filePath)
})
// db.execの実行結果を返すならOK
ipcMain.handle('get', async(event) => {
    if (!lib.has(`SQL`)) { loadDb() }
    const res = lib.get(`DB`).exec(`select * from comments order by created desc;`)
    return res[0].values
})
ipcMain.handle('insert', async(event, r)=>{
    if (!lib.has(`SQL`)) {loadDb()}
    const record = lib.get(`DB`).exec(`insert into comments(content, created) values('${r.content}', r.created);`)
    console.log(record)
    return record
})
ipcMain.handle('clear', async(event)=>{
    lib.get(`DB`).exec(`delete from comments;`)
})
ipcMain.handle('delete', async(event, ids)=>{
    lib.get(`DB`).exec(`begin;`)
    for (const id of ids) {
        lib.get(`DB`).exec(`delete from comments where id = ${id};`)
    }
    lib.get(`DB`).exec(`commit;`)
})
ipcMain.handle('exportDb', async(event)=>{
    return lib.get(`DB`).export()
})

前回より改善できた。

  • なぜかelectron 20.0.1でもトップレベルでconst fs = require('fs')できた(前回と何が違うのか不明)
  • new SQL.Databaseを共有し繰り返さぬためlibで保持した
  • new SQL.Databaseを返したらexec関数が存在せず消えてしまうのは変わらない
    • なのでユースケースごとにメソッドを作成した

preload.js

const {remote,contextBridge,ipcRenderer} =  require('electron');
contextBridge.exposeInMainWorld('myApi', {
    loadDb:async(filePath)=>await ipcRenderer.invoke('loadDb', filePath),
    get:async()=>await ipcRenderer.invoke('get'),
    insert:async(record)=>await ipcRenderer.invoke('insert', record),
    clear:async()=>await ipcRenderer.invoke('delete'),
    delete:async(ids)=>await ipcRenderer.invoke('delete', ids),
    exportDb:async()=>await ipcRenderer.invoke('exportDb'),
})

renderer.js

window.addEventListener('DOMContentLoaded', async(event) => {
    await window.myApi.loadDb(`src/db/mylog.db`)
    const db = new MyLogDb()
    const LENGTH = 140
    const LINE = 15
    Loading.setup()
    document.getElementById('post-list').innerHTML = await db.toHtml()
    document.getElementById('content').focus()
    document.getElementById('content-length').textContent = LENGTH;
    document.querySelector('#post').addEventListener('click', async()=>{
        document.getElementById('post-list').innerHTML = 
            db.insert(document.getElementById('content').value)
            + document.getElementById('post-list').innerHTML
    })
    document.querySelector('#delete').addEventListener('click', async()=>{
        const ids = Array.from(document.querySelectorAll(`#post-list input[type=checkbox][name=delete]:checked`)).map(d=>parseInt(d.value))
        db.delete(ids)
    })
});

index.html

<!doctype html>
<meta charset="utf-8">
<script src="src/js/util/loading.js"></script>
<script src="src/js/app/text-to-html.js"></script>
<script src="src/js/app/mylog-db.js"></script>
<script src="src/js/app/mylog-downloader.js"></script>
<script src="src/js/app/mylog-uploader.js"></script>

<div>
<textarea id="content" value="" placeholder="つぶやく内容またはDBファイルをドラッグ&ドロップ" cols="40" rows="5"></textarea>
<span>あと<span id="content-length"></span>字<span>
<button id="post" type="button">つぶやく</button>
<button id="delete" type="button">削除</button>
</div>
<div id="post-list"></div>
<script src="./src/js/renderer.js"></script>

所感

WEB版で実装していたときと全然違うコードを書かねばならなかった。ファイル入出力部分は丸ごとElectronやNode.jsの文脈で書かねばならない。

そのおかげで、FileSystemAccessAPIの煩わしいダイアログ確認せずファイル入出力ができるので、アプリのユーザとしては嬉しいのだが。

コスパが悪いと感じる。とくにpreload.jsにあるIPC通信のインタフェース部分。こういう実にならないコードを書かずに済ませたい。

Electronでの作法もこれで合っているのかわからない。他になにかいい書き方があるのかもしれない。手探りでやるしかない。