投げモナボタンの画像だけは表示できるようにしたが、機能しない。

ブツ

DEMOは以下エラーになる。IPC通信の部分はHTTPS上で動作しない。

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

インストール&実行

ろーか ローカルで実行すると成功する。

NAME='Electron.sql.js.MyLog.20220808113834'
git clone https://github.com/ytyaru/$NAME
cd $NAME
npm install
npm start
  1. アドレスに自分のアドレスを入力する
  2. 保存ボタンを押す
  3. メニュー→ViewForce Reloadを押す(Ctrl+ShiftR
  4. 投げモナボタンが表示される
  5. ただしボタンを押しても機能しない

投げモナが機能しない

投げモナボタンはmpurse APIで実装している。mpurse APIはmpchain APIを叩くことで支払いを実現している。APIを叩くためにはHTTPS上でなければならない。だがローカルで実行するとHTTPSではないため機能しない。

そもそもElectronではブラウザ拡張であるmpurseをロードできない。よってmpurse APIを使えず投げモナできない。ボタンを押すと次のようなエラーが出る。

Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'sendAsset')
    at MpurseSendButton.<anonymous> (mpurse-send-button.js:150:48)Uncaught (in promise) TypeError: Cannot read properties of undefined (reading 'sendAsset')
    at MpurseSendButton.<anonymous> (mpurse-send-button.js:150:48)

送金メソッドsendAssetの前がundefinedだとエラーが出る。コードは以下。ようするにmpurseをロードできずwindow.mpurseundefinedなのでエラーになった。

const txHash = await window.mpurse.sendAsset(to, asset, amount, memoType, memo).catch((e) => null);

どうしたものか。

eyecatch.png

コード抜粋

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"
  }
}

npm startでアプリを起動するよう実装した。src/js/main.jsを呼び出す。

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`)
}
function readFile(path, kwargs) { return fs.readFileSync(path, kwargs) }

// ここではdb.execを参照できるが、return後では参照できない謎
ipcMain.handle('loadDb', async(event, filePath=null) => {
    console.log('----- loadDb ----- ', filePath)
    return loadDb(filePath)
})
// db.execの実行結果を返すならOK
ipcMain.handle('get', async(event) => {
    console.log('----- get ----- ')
    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()}
    console.debug(r)
    lib.get(`DB`).exec(`insert into comments(content, created) values('${r.content}', ${r.created});`)
    const res = lib.get(`DB`).exec(`select * from comments where created = ${r.created};`)
    return res[0].values[0]
})
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()
})
ipcMain.handle('existFile', (event, path)=>{ return fs.existsSync(path) })
ipcMain.handle('readFile', (event, path, kwargs)=>{ return readFile(path, kwargs) })
ipcMain.handle('readTextFile', (event, path, encoding='utf8')=>{ return readFile(path, { encoding: encoding }) })
ipcMain.handle('writeFile', (event, path, data)=>{ return fs.writeFileSync(path, data) })

ウインドウを作る。EelectronやNode.jsなどローカル操作するためのAPI処理を実装する。

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'),
    existFile:async(path)=>await ipcRenderer.invoke('existFile', path),
    readFile:async(path, kwargs)=>await ipcRenderer.invoke('readFile', path, kwargs),
    readTextFile:async(path, encoding='utf8')=>await ipcRenderer.invoke('readTextFile', path, encoding),
    writeFile:async(path, data)=>await ipcRenderer.invoke('writeFile', path, data),
})

main.jsのAPIをIPC経由で呼び出すためのインタフェースを作る。

renderer.js

window.addEventListener('DOMContentLoaded', async(event) => {
    console.log('DOMContentLoaded!!');
    await window.myApi.loadDb(`src/db/mylog.db`)
    const db = new MyLogDb()
    Loading.setup()
    const setting = await Setting.load()
    console.log(setting)
    console.log(setting?.mona?.address)
    if (setting?.mona?.address) { document.getElementById('address').value = setting.mona.address }
    document.getElementById('post-list').innerHTML = await db.toHtml(document.getElementById('address').value)
    document.getElementById('content').focus()
    document.getElementById('content-length').textContent = db.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))
        console.debug(ids)
        await db.delete(ids)
        document.getElementById('post-list').innerHTML = await db.toHtml()
    })
    document.querySelector('#save-setting').addEventListener('click', async()=>{
        await Setting.save({mona:{address:document.getElementById('address').value},github:{username:"",token:"",repository:""}})
    })
})

HTML側で実行する。

index.html

<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>
<fieldset><legend>設定</legend>
<input type="text" id="address" placeholder="Mpurseアドレス"></input>
<button id="save-setting" type="button">保存</button>
</fieldset>
</div>
<div id="post-list"></div>
<script src="./src/js/renderer.js"></script>

画面要素を定義する。renderer.jsを呼び出す。

setting.js

アプリの設定をファイルに保存する。読み込む。

class Setting {
    static PATH = `src/db/setting.json`
    static async load() {
        const isExist = await window.myApi.existFile(this.PATH)
        if (!isExist) { await window.myApi.writeFile(this.PATH, JSON.stringify(
            {mona:{address:""},github:{username:"",token:"",repository:""}}
        )) }
        return JSON.parse(await window.myApi.readTextFile(this.PATH))
    }
    static async save(obj) { return await window.myApi.writeFile(this.PATH, JSON.stringify(obj)) }
}

将来は保存したアドレスをもちいて投げモナボタンを機能させたい。また、それが機能するためのHTMLを出力し、GitHubにアップロードできるようにしたい。毎回入力するのは面倒だから設定ファイルとして保存・読込する処理をここに書いた。

mylog-db.js

DB操作。

class MyLogDb {
    constructor() {
        this.now = new Date()
        this.LENGTH = 140
        this.LINE = 15
    }
    async clear() { await window.myApi.clear() }
    async delete(ids) {
        console.debug(ids)
        const isAll = (0===ids.length)
        const msg = ((isAll) ? `つぶやきをすべて削除します。` : `選択したつぶやきを削除します。`) + `\n本当によろしいですか?`
        if (confirm(msg)) {
            console.debug('削除します。')
            if (isAll) { console.debug('全件削除します。'); await window.myApi.clear() }
            else { console.debug('選択削除します。'); await window.myApi.delete(ids) }
        }
    }
    async insert(content, address=null) {
        if (!content) { alert('つぶやく内容をテキストエリアに入力してください。'); return; }
        if (this.LENGTH < content.length) { alert(`つぶやく内容は${this.LENGTH}字以内にしてください。`); return; }
        const match = content.match(/\r\n|\n/g)
        if (match && this.LINE < match.length) { alert(`つぶやく内容は${this.LINE}行以内にしてください。`); return; }
        const now = Math.floor(new Date().getTime() / 1000)
        const r = window.myApi.insert({content:content, created:now});
        return TextToHtml.toHtml(r.id, r.content, r.created, address)
    }
    async toHtml(address=null) {
        const cms = await window.myApi.get()
        return cms.map(c=>TextToHtml.toHtml(c[0], c[1], c[2], address)).join('')
    }
}

DB処理はsql.jsのメソッドで実行する。それはmain.jsに実装されている。ここからそれを呼び出す。その前にUIで確認させる。

text-to-html.js

入力されたテキストをHTMLにパースする。

class TextToHtml {
    static now = new Date()
    static toHtml(id, content, created, address=null, isFixedHtml=false) {
        console.log(address)
        return `<a id="${id}" class="anchor"></a><div class="mylog"><p>${this.br(this.autoLink(content))}</p><div class="mylog-meta">${this.#toTime(created, isFixedHtml)}${(isFixedHtml) ? '' : this.#toDeleteCheckbox(id)}<a href="#${id}">🔗</a>${this.#toMpurseButton(address)}</div></div>`
    }
    static #toMpurseButton(address=null) {
        return (address) ? `<mpurse-send-button to="${address}"></mpurse-send-button>` : ''
    }
    static #toTime(created, isFixedHtml=false) {
        const d = new Date(created * 1000)
        const u = d.toISOString()
        const l = (isFixedHtml) ? d.toLocaleString({ timeZone: 'Asia/Tokyo' }).replace(/\//g, '-') : this.#toElapsedTime(created)
        return `<time datetime="${u}" title="${u}">${l}</time>`
    }
    static #toElapsedTime(created) {
        const d = new Date(created * 1000)
        const dates = Math.floor((this.now.getTime() - d.getTime()) / (1000 * 60 * 60 * 24))
        if (365 < dates) {return `${d.getFullYear()}-${(d.getMonth()+1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`} // 一年以上前
        else if (0 < dates) { // 一日以上
            return `${(d.getMonth()+1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`
        }
        else { return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}` } // 当日
    }
    static #toDeleteCheckbox(id) { return `<label><input type="checkbox" name="delete" value="${id}">❌<label>` }

    static br(str) { return str.replace(/\r\n|\n/g, '<br>') }
    static autoLink(str) {
        let res = this.autoMedia(str); if (str !== res) { return res }
        return this.autoLinkIpfs(this.autoLinkHttps(str)) }
    static autoLinkHttps(str) { // https://twitter.com/
        const regexp_url = /((h?)(ttps?:\/\/[a-zA-Z0-9.\-_@:/~?%&;=+#',()*!]+))/g; // ']))/;
        return str.replace(
            regexp_url, 
            (all, url, h, href)=>`<a href="h${href}">${url}</a>`
        );
    }
    static autoLinkIpfs(str) { // ipfs://QmZZrDCuCV5A3WsxbbC6UCtrHtNs2eVyfJwF7JcJJoJGwV
        const regexp_url = /((ipfs?:\/\/[a-zA-Z0-9.\-_@:/~?%&;=+#',()*!]+))/g; // ']))/;
        return str.replace(
            regexp_url, 
            (all, url, h, href)=>`<a href="${url}">${url}</a>`
        );
    }
    static autoMedia(str) {
        let res = this.autoImg(str); if (str !== res) { return res }
        res = this.autoVideo(str); if (str !== res) { return res }
        res = this.autoAudio(str); if (str !== res) { return res }
        return str
    }
    static autoImg(str) {
        const regexp_url = /((https?:\/\/[a-zA-Z0-9.\-_@:/~?%&;=+#',()*!]+\.(png|gif|jpg|jpeg|webp|avif)))/g; // ']))/;
        return str.replace(regexp_url, (all, url, href)=>`<img src="${href}">`)
    }
    static autoVideo(str) {
        let res = this.autoVideoFile(str); if (str !== res) { return res }
        res = this.autoVideoYoutube(str); if (str !== res) { return res }
        return str
    }
    static autoVideoFile(str) {
        const regexp_url = /((https?:\/\/[a-zA-Z0-9.\-_@:/~?%&;=+#',()*!]+\.(mp4|avi|wmv|mpg|flv|mov|webm|mkv|asf)))/g; // ']))/;
        return str.replace(regexp_url, (all, url, href)=>`<video controls width="320" src="${url}"></video>`)
    }
    static autoVideoYoutube(str) { // https://www.youtube.com/watch?
        const regexp_url = /https:\/\/www.youtube.com\/watch\?v=([a-zA-Z0-9.\-_@:/~?%&;=+#',()*!]+)/
        const match = str.match(regexp_url)
        if (match && 1 < match.length) {
            return `<iframe width="320" height="240" src="https://www.youtube.com/embed/${match[1]}" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>`
        }
        return str
    }
    static autoAudio(str) {
        const regexp_url = /((https?:\/\/[a-zA-Z0-9.\-_@:/~?%&;=+#',()*!]+\.(wav|mp3|ogg|flac|wma|aiff|aac|m4a)))/g; // ']))/;
        return str.replace(regexp_url, (all, url, href)=>`<audio controls width="320" src="${url}"></audio>`)
    }
}