ElectronでつかわれているIPC(プロセス間通信)について、調べてみました。
サンプルコード
https://github.com/saltyshiomix/nextron/tree/main/examples/with-material-ui
https://decode.red/ed/archives/1661
上記の最後で簡単に実行してみましたが、セキュリティ部分について下記を参考にしました。
nodeIntegration
https://zenn.dev/sprout2000/books/6f6a0bf2fd301c/viewer/13340
contextBridge
https://zenn.dev/sprout2000/books/6f6a0bf2fd301c/viewer/13344
helpers/create-window.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 |
export const createWindow = ( windowName: string, options: BrowserWindowConstructorOptions ): BrowserWindow => { const key = 'window-state' const name = `window-state-${windowName}` const store = new Store<Rectangle>({ name }) const defaultSize = { width: options.width, height: options.height, } let state = {} const restore = () => store.get(key, defaultSize) const getCurrentPosition = () => { const position = win.getPosition() const size = win.getSize() return { x: position[0], y: position[1], width: size[0], height: size[1], } } const windowWithinBounds = (windowState, bounds) => { return ( windowState.x >= bounds.x && windowState.y >= bounds.y && windowState.x + windowState.width <= bounds.x + bounds.width && windowState.y + windowState.height <= bounds.y + bounds.height ) } const resetToDefaults = () => { const bounds = screen.getPrimaryDisplay().bounds return Object.assign({}, defaultSize, { x: (bounds.width - defaultSize.width) / 2, y: (bounds.height - defaultSize.height) / 2, }) } const ensureVisibleOnSomeDisplay = (windowState) => { const visible = screen.getAllDisplays().some((display) => { return windowWithinBounds(windowState, display.bounds) }) if (!visible) { // Window is partially or fully not visible now. // Reset it to safe defaults. return resetToDefaults() } return windowState } const saveState = () => { if (!win.isMinimized() && !win.isMaximized()) { Object.assign(state, getCurrentPosition()) } store.set(key, state) } state = ensureVisibleOnSomeDisplay(restore()) const win = new BrowserWindow({ ...state, ...options, webPreferences: { nodeIntegration: false, contextIsolation: true, ...options.webPreferences, }, }) win.on('close', saveState) return win } |
上記コードの下記の部分で設定されます。(デフオルト設定)
nodeIntegration: false,
contextIsolation: true,
nodeと統合するか、コンテントを分離するか、という意味ですが、つまりchroniumブラウザで走るフロントエンドプログラムと、nodeで動くサーバプログラムを切り離すかどうかの制御をする部分です。
通常のWebアプリでOSの機能にアクセスできないのは、セキュリティ上あたりまえのことですので、これはデフォルト設定のままでいいと思います。
上記参考サイトでは、実例が詳しく説明されています。
ここでは、参考サイトにあったダイアログの開く部分について、サンプルに統合してみました。
記述の仕方が違うので、興味をもちました。
preload.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron' const handler = { send(channel: string, value: unknown) { ipcRenderer.send(channel, value) }, on(channel: string, callback: (...args: unknown[]) => void) { const subscription = (_event: IpcRendererEvent, ...args: unknown[]) => callback(...args) ipcRenderer.on(channel, subscription) return () => { ipcRenderer.removeListener(channel, subscription) } }, async openDialog(){ const ret = await ipcRenderer.invoke('open-dialog') console.log('ret', ret) return ret} } contextBridge.exposeInMainWorld('ipc', handler) export type IpcHandler = typeof handler |
background.ts
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
import path from 'path' import { app, ipcMain, dialog } from 'electron' import serve from 'electron-serve' import { createWindow } from './helpers' const isProd = process.env.NODE_ENV === 'production' if (isProd) { serve({ directory: 'app' }) } else { app.setPath('userData', `${app.getPath('userData')} (development)`) } ;(async () => { await app.whenReady() const mainWindow = createWindow('main', { width: 1000, height: 600, webPreferences: { preload: path.join(__dirname, 'preload.js'), }, }) if (isProd) { await mainWindow.loadURL('app://./home') } else { const port = process.argv[2] await mainWindow.loadURL(`http://localhost:${port}/home`) mainWindow.webContents.openDevTools() } ipcMain.handle('open-dialog', async (_e, _arg) => { return dialog .showOpenDialog(mainWindow, { properties: ['openFile'], }) .then((result) => { if (result.canceled) return ''; return result.filePaths[0]; }); }); })() app.on('window-all-closed', () => { app.quit() }) ipcMain.on('message', async (event, arg) => { event.reply('message', `${arg} World!`) }) |
呼び出し側
renderer/pages/home.tsx
1 2 3 4 5 6 7 8 9 |
export default function HomePage() { const [open, setOpen] = React.useState(false) const handleClose = () => setOpen(false) const handleClick = async() => { setOpen(true) const ret = await window.ipc.openDialog() console.log('click', ret) } ..... |
サンプルプログラムの実装を借りての確認のため、意味のない挙動をしますが、ダイアログで開いたファイルパスをレンダー側で表示させることが目的です。
ハンドラーの登録の仕方が、サンプルでは汎用的な方法で書かれていますが、参考サイトの個別のやり方の方がわかりやすかったので、この方法で実装してみました。
preloadで書かれているhandlerのメソッドの書き方、知りませんでした。