iPad 第11世代 第10世代 ケース 2025/2022 11インチ Dadanism iPad 11世代 A16/10世代 10.9インチ 2025年新モデル ケース iPad 11 10.9 2022 iPad 11 10 アイパッド用 タブレットケース オートスリープ 三つ折り スタンドケース PU+PC 耐久 Apple Pencil対応 カバー ネイビーブルー
¥1,458 (2025年4月25日 13:08 GMT +09:00 時点 - 詳細はこちら価格および発送可能時期は表示された日付/時刻の時点のものであり、変更される場合があります。本商品の購入においては、購入の時点で当該の Amazon サイトに表示されている価格および発送可能時期の情報が適用されます。)
この記事はあくまで個人の見解です。もうちょっといい方法もあると思うので、あくまで方法の1つと思ってください。
また用語等の誤用もあるかもしれませんが、温かい目で見逃していただけると幸いです。
※ contextBridge への日頃の恨みつらみなので飛ばしてもらって構わないです。
みなさん Electron つかっていますか。
Chromium ベースでフロントは Node.js に対応しているので、シンプルなアプリケーションであればすぐにマルチプラットフォームにソフトウェアが作れるのでとても便利ですね。
しかし、 Electron には最凶の仕様をもつ輩がいます。
ipc 通信
こいつです。
憎いです。
ソフトウェアの内部システムに干渉するためには、 ipc 通信でフロントとの通信をすることで様々な機能を実装できます。
しかし、この ipc 通信には邪悪な点がたくさんあります。
- ipcMain, ipcRenderer の使い分け
Electron には、preload
とmain
の2つの区間が存在し、フロントと通信するためには一度preload
を介さないといけません。しかし、preload
で使える機能とmain
で使える機能には制限があり、main
で使う機能のためのpreload
の設定をしたいけど、その設定のためにはmain
で使う関数を使わないといけない…
等々、かなり複雑に行ったり来たりしていまうことも多々あるでしょう。 - コードのスパゲティ
Electron で内部の機能をもった API を作成するためには、
- `main` プロセスで実際の機能を実装
- `preload` プロセスで `main` とフロントをつなぐために contextBridge を使う
- フロントで使うための関数の型定義
なんと、同じ1つの関数を使うために 3 個所 も同じ名前であったり関数を記述しないといけないのです。ひどいですね。そうですわかります。
これらを解決するために、いろいろ調べて自分の中での今出せる最適解を見つけましたので、メモも兼ねて共有します。
解説は後に回すとして、まずは実際のコードを見てもらいましょう。
再帰的かつエラーハンドリングや型定義も兼ねているのでファイルの数も多いです。
/tsconfig.json
/tsconfig.json
{
"files": [],
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }, { "path": "./tsconfig.preload.json" }],
"compilerOptions": {
"forceConsistentCasingInFileNames": true,
"strict": true
}
}
/tsconfig.node.json
/tsconfig.node.json
{
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
"include": [
"electron.vite.config.*",
"src/main/**/*",
"src/preload/**/*",
"src/renderer/**/*",
"src/**/*.(d.ts|ts|js)",
"src/global.ts",
"src/renderer/renderer.d.ts",
],
"compilerOptions": {
"strict": true,
"skipLibCheck": false,
"composite": true,
"moduleResolution": "node16",
"module": "Node16",
"typeRoots": [
"./node_modules/@types",
"./src/types/global",
],
}
}
/tsconfig.preload.json
/tsconfig.preload.json
{
"extends": "./tsconfig.node.json",
"compilerOptions": {
"module": "CommonJS",
"outDir": "./out/preload",
"moduleResolution": "node",
},
"include": [
"src/preload/**/*",
],
}
/tsconfig.web.json
/tsconfig.web.json
{
"extends": "@electron-toolkit/tsconfig/tsconfig.web.json",
"include": [
"src/renderer/src/env.d.ts",
"src/renderer/src/**/*",
"src/renderer/src/**/*.ts",
"src/renderer/*.d.ts",
"src/renderer/renderer.d.ts",
],
"compilerOptions": {
"composite": true,
"verbatimModuleSyntax": true,
"useDefineForClassFields": true,
"resolveJsonModule": true,
"strict": true,
"allowJs": true,
"checkJs": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
}
}
/src/main/index.ts
/src/main/index.ts
import { apiHandlers, registerAPIHandlers } from '../preload/utils/api/handler.js' // ts.node.json にて module を node16 に設定しているため、 .ts ではなく .js でインポート
app.whenReady().then(() => {
electronApp.setAppUserModelId('com.electron')
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window)
})
createWindow()
// ハンドラーを API として登録
registerAPIHandlers(apiHandlers)
app.on('activate', function () {
if (BrowserWindow.getAllWindows().length === 0) createWindow()
})
})
/src/preload/index.ts
/src/preload/index.ts
import { contextBridge } from 'electron'
import { electronAPI } from '@electron-toolkit/preload'
import { createAPIInvoker, apiHandlers, type APIHandler } from './utils/api/handler.js'
import { apiListeners, type APIListeners } from './utils/api/listener.js'
import { logStatus } from './utils/logStatus.js'
// 開発環境用に一応分けておく
if (process.contextIsolated) {
try {
contextBridge.exposeInMainWorld('electron', electronAPI)
const APIRenderer = createAPIInvoker(apiHandlers)
contextBridge.exposeInMainWorld('api', {
invoke: APIRenderer,
listeners: apiListeners
})
} catch (error) {
logStatus(
{ code: 500, message: 'contextBridge による API のエクスポートに失敗しました' },
null,
error
)
}
} else {
window.electron = electronAPI
window.api = {
invoke: createAPIInvoker(apiHandlers) as unknown as APIHandler,
listeners: apiListeners as APIListeners
}
logStatus({ code: 200, message: 'window による API のエクスポートに成功しました' })
}
/src/preload/index.d.ts
/src/preload/index.d.ts
/**
/src/renderer.d.ts も同じ。インポートは適宜変更
*/
import { ElectronAPI } from '@electron-toolkit/preload'
import type { APIHandler } from './utils/api/handler.js'
import type { APIListeners } from './utils/api/listener.js'
declare global {
interface Window {
electron: ElectronAPI
api: {
invoke: APIHandler
listeners: APIListeners
}
}
}
/src/preload/types/index.ts
/src/preload/types/index.ts
/**
* API のレスポンスのスキーマ
* @template T API のレスポンスのデータの型
*/
type APIRecordT> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: ((...args: any[]) => PromiseT>) | APIRecordT>
}
type Status = {
code: number
message?: string
}
/**
* API のレスポンスのスキーマ
*/
type APISchemaT = object | string | Buffer | null> = {
status: Status
data?: T
error?: string | Error | unknown
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AsyncFunction = (...args: any[]) => Promiseany>
type RecursiveAPIT> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[K in keyof T]: T[K] extends (...args: any[]) => any
? (...args: ParametersT[K]>) => ReturnTypeT[K]>
: RecursiveAPIT[K]>
}
export type { APIRecord, Status, APISchema, AsyncFunction, RecursiveAPI }
/src/preload/utils/logStatus.ts
/src/preload/utils/logStatus.ts
import type { Status, APISchema } from '../types/index.js'
const statusSchema: { [key: string]: { [key: number]: { message: string } } } = {
SUCCESS: {
200: {
message: 'Success'
}
},
WARN: {
300: {
message: 'Warning'
},
301: {
message: 'Redirect'
},
302: {
message: 'Found'
},
304: {
message: 'Not Modified'
}
},
ERROR: {
400: {
message: 'Bad Request'
},
401: {
message: 'Unauthorized'
},
403: {
message: 'Forbidden'
},
404: {
message: 'Not Found'
},
500: {
message: 'Internal Server Error'
}
}
}
/**
* ステータスをログに出力する
* @param message ログに出力するメッセージ
* @param status ログに出力するステータス ('SUCCESS' または 'ERROR')
* @param error エラーが発生した場合のエラーオブジェクトまたはエラーメッセージ (オプション)
*/
const logStatus = T extends object | unknown>(
status: Status,
message: T = {} as T,
error?: unknown
): APISchemaT> => {
const statusType = Object.keys(statusSchema).find((key) => statusSchema[key][status.code])
let logMessage = status.message
if (!logMessage) {
if (statusType) {
logMessage = statusSchema[statusType][status.code].message
} else {
logMessage = statusType === 'SUCCESS' ? 'Success' : 'Internal Server Error'
}
}
if (statusType === 'SUCCESS') {
console.log(`[SUCCESS] ${logMessage}`)
return { status: { code: status.code, message: logMessage }, data: message }
} else if (statusType === 'ERROR') {
console.error(`[ERROR] ${logMessage}`)
if (error) {
const errorMessage = error instanceof Error ? error.message : error
console.error(` └─ ${errorMessage}`)
return { status, error: errorMessage }
}
return { status, error: logMessage }
} else if (statusType === 'WARN') {
console.warn(`[WARN] ${logMessage}`)
return { status, data: message }
} else {
console.error(`[ERROR] Invalid status code: ${status.code}`)
return { status, error: 'Internal Server Error' }
}
}
export { logStatus }
/src/preload/utils/api/handler.ts
/src/preload/utils/api/handler.ts
import { app, BrowserWindow, ipcMain, ipcRenderer } from 'electron'
import { APIRecord, APISchema, RecursiveAPI } from '../../types/index.js'
import { logStatus } from '../logStatus.js'
import { v4 as uuid } from 'uuid'
import { registerAPIListeners } from './listener.js'
import type { FileStructure, Project } from '../../../global.js'
import path from 'path'
import fs from 'fs'
/**
* API のハンドラ郡
*/
const apiHandlers = {
project: {
create: async (project: Project): PromiseAPISchemaProject | null>> => {
const userDirPath = app.getPath('userData')
const projectsPath = path.join(userDirPath, 'project/projects')
try {
const projects = fs.readdirSync(projectsPath)
let projectId = uuid()
while (projects.includes(projectId)) {
projectId = uuid()
}
console.log('projectId', projectId)
project.id = projectId
const projectPath = path.join(projectsPath, projectId)
fs.mkdirSync(projectPath, { recursive: true })
const projectFilePath = path.join(projectPath, 'project.json')
fs.writeFileSync(projectFilePath, JSON.stringify(project, null, 2))
return logStatus(
{ code: 200, message: 'プロジェクトを作成しました' },
{ id: projectId, name: project.name, description: project.description, icon: project.icon, tags: project.tags, status: project.status } as Project,
)
} catch (err) {
return logStatus({ code: 500, message: 'プロジェクトの作成に失敗しました' }, null, err)
}
},
delete: async (projectId: string): PromiseAPISchemanull>> => {
const userDirPath = app.getPath('userData')
const projectsPath = path.join(userDirPath, 'project/projects')
try {
const projectPath = path.join(projectsPath, projectId)
fs.rmSync(projectPath, { recursive: true, force: true })
return logStatus({ code: 200, message: 'プロジェクトを削除しました' }, null)
} catch (err) {
return logStatus({ code: 404, message: 'プロジェクトが見つかりません' }, null, err)
}
},
}
} satisfies APIRecordAPISchema>
/**
* API のハンドラを登録する
* @param apiObj API のハンドラ郡
* @param parentKey 親のキー
*/
const registerAPIHandlers = T>(apiObj: APIRecordT>, parentKey = ''): void => {
for (const key in apiObj) {
const fullKey = parentKey ? `${parentKey}.${key}` : key
if (typeof apiObj[key] === 'function') {
ipcMain.handle(`invoke-api:${fullKey}`, async (_event, ...args) => {
try {
return await (apiObj[key] as (...args: unknown[]) => PromiseT>)(...args)
} catch (err) {
return logStatus({ code: 500, message: 'API の呼び出しに失敗しました' }, null, err)
}
})
} else {
registerAPIHandlers(apiObj[key] as APIRecordT>, fullKey)
}
}
registerAPIListeners(apiObj, parentKey)
}
/**
* API のインボーカを作成する
* @param apiObj API のハンドラ郡
* @param parentKey 親のキー
* @returns API のインボーカ
*/
const createAPIInvoker = T>(apiObj: APIRecordT>, parentKey = ''): RecursiveAPIT> => {
const apiRenderer: PartialRecursiveAPIT>> = {}
for (const key in apiObj) {
const fullKey = parentKey ? `${parentKey}.${key}` : key
if (typeof apiObj[key] === 'function') {
apiRenderer[key] = async (...args: unknown[]): PromiseAPISchema> => {
return ipcRenderer.invoke(`invoke-api:${fullKey}`, ...args)
}
} else {
apiRenderer[key] = createAPIInvoker(apiObj[key] as APIRecordT>, fullKey)
}
}
return apiRenderer as RecursiveAPIT>
}
type APIHandler = RecursiveAPItypeof apiHandlers>
export { apiHandlers, registerAPIHandlers, createAPIInvoker, type APIHandler }
/src/preload/utils/api/ipcManager.ts
/src/preload/utils/api/ipcManager.ts
import { ipcRenderer } from 'electron'
import { logStatus } from '../logStatus.js'
type ListenerT = unknown> = (...args: T[]) => void
const ipcManager = {
listeners: new Mapstring, Listener[]>(),
onT = unknown[]>(channel: string, listener: ListenerT>): () => void {
if (!this.listeners.has(channel)) {
this.listeners.set(channel, [])
}
const channelListeners = this.listeners.get(channel)!
channelListeners.push(listener as Listener)
const wrappedListener = ((_: Electron.IpcRendererEvent, ...args: T[]): void => {
listener(...args)
}) as Listenerunknown>
ipcRenderer.on(channel, wrappedListener)
logStatus(
{ code: 200, message: `リスナーを登録しました: ${channel}` }
)
return () => {
this.off(channel, wrappedListener)
}
},
off(channel: string, listener: Listener): void {
const channelListeners = this.listeners.get(channel)
if (channelListeners) {
const index = channelListeners.indexOf(listener)
if (index !== -1) {
channelListeners.splice(index, 1)
ipcRenderer.removeListener(channel, listener)
}
if (channelListeners.length === 0) {
this.listeners.delete(channel)
}
logStatus(
{ code: 200, message: `リスナーを削除しました: ${channel}` }
)
} else {
logStatus(
{ code: 404, message: `リスナーが見つかりません: ${channel}` },
null,
new Error(`リスナーが見つかりません: ${channel}`)
)
}
},
onceT = unknown[]>(channel: string, listener: ListenerT>): void {
const wrappedListener = ((...args: T[]): void => {
listener(...args)
this.off(channel, wrappedListener)
}) as Listenerunknown>
this.on(channel, wrappedListener)
logStatus(
{ code: 200, message: `リスナーを登録しました: ${channel}` }
)
}
}
export { ipcManager }
/src/preload/utils/api/listener.ts
/src/preload/utils/api/listener.ts
import { ipcMain, ipcRenderer } from 'electron'
import { ipcManager } from './ipcManager.js'
import { logStatus } from '../logStatus.js'
import type { APISchema, APIRecord, RecursiveAPI } from '../../types/index.js'
const apiListeners = {
stream: {
onResponse: T extends unknown[]>(
callback: (args: T) => void
): PromiseAPISchema() => void>> => {
const safeCallback = (...args: unknown[]): void => {
callback(args as T)
}
ipcManager.on('stream:response', safeCallback)
return new Promise((resolve) => {
const removeListener = (): void => {
ipcManager.off('stream:response', safeCallback)
}
return resolve(
logStatus(
{ code: 200, message: 'stream:response リスナーを登録しました' },
removeListener
)
)
})
}
}
} satisfies APIRecordAPISchema>
const registerAPIListeners = T>(apiObj: APIRecordT>, parentKey = ''): void => {
console.log(`[IPC] Registering listeners for path: ${parentKey}`)
for (const key in apiObj) {
const fullKey = parentKey ? `${parentKey}.${key}` : key
if (typeof apiObj[key] === 'function') {
ipcMain.on(`on-api:${fullKey}`, (_event, ...args) => {
try {
;(apiObj[key] as (...args: unknown[]) => PromiseT>)(...args)
} catch (err) {
console.error(`[ERROR] IPC Listener error: ${fullKey}`, err)
}
})
} else {
registerAPIListeners(apiObj[key] as APIRecordT>, fullKey)
}
}
}
/**
* API のエミッターを作成する
* @param apiObj API のリスナー郡
* @param parentKey 親のキー
* @returns API のエミッター
*/
const createAPIEmitter = T>(apiObj: APIRecordT>, parentKey = ''): RecursiveAPIT> => {
const apiEmitter: PartialRecursiveAPIT>> = {}
for (const key in apiObj) {
const fullKey = parentKey ? `${parentKey}.${key}` : key
if (typeof apiObj[key] === 'function') {
apiEmitter[key] = (...args: unknown[]): void => {
ipcRenderer.send(`on-api:${fullKey}`, ...args)
}
} else {
apiEmitter[key] = createAPIEmitter(apiObj[key] as APIRecordT>, fullKey)
}
}
return apiEmitter as RecursiveAPIT>
}
type APIListeners = RecursiveAPItypeof apiListeners>
export { apiListeners, registerAPIListeners, createAPIEmitter, type APIListeners }
tsconfig 系
API の作成方法とか言っておきながら、一緒にビルドのための諸設定も書いておきました。
ただ、 tsconfig.preload.json
の node16
の設定等はしないとインポートにエラーが出るので注意が必要です。
それ以外は各々必要な設定をしていただいても構いませんが、ビルドまで含めたら最低限の設定にはなってるかなと。
preload
logStatus
const statusSchema: { [key: string]: { [key: number]: { message: string } } } = {
SUCCESS: {
200: {
message: 'Success'
}
},
ERROR: {
400: {
message: 'Bad Request'
},
404: {
message: 'Not Found'
},
500: {
message: 'Internal Server Error'
}
}
}
const logStatus = T extends object | unknown>(
status: Status,
message: T = {} as T,
error?: unknown
): APISchemaT> => {
const statusType = Object.keys(statusSchema).find((key) => statusSchema[key][status.code])
let logMessage = status.message
if (!logMessage) {
if (statusType) {
logMessage = statusSchema[statusType][status.code].message
} else {
logMessage = statusType === 'SUCCESS' ? 'Success' : 'Internal Server Error'
}
}
if (statusType === 'SUCCESS') {
console.log(`[SUCCESS] ${logMessage}`)
return { status: { code: status.code, message: logMessage }, data: message }
} else if (statusType === 'ERROR') {
console.error(`[ERROR] ${logMessage}`)
if (error) {
const errorMessage = error instanceof Error ? error.message : error
console.error(` └─ ${errorMessage}`)
return { status, error: errorMessage }
}
return { status, error: logMessage }
} else if (statusType === 'WARN') {
console.warn(`[WARN] ${logMessage}`)
return { status, data: message }
} else {
console.error(`[ERROR] Invalid status code: ${status.code}`)
return { status, error: 'Internal Server Error' }
}
}
こちらは、 API と直接関係ありませんが、完結で安全な開発のために追加しました。
各種ログを、ステータスコードや実際の返り値、あるいはエラーを各所でログとして出力してくれます。
フロントだけでなくメインプロセスでもログを同時に出してくれるので、どの段階でデータがどのような状態を保持しているかが確認できます。
Handler
Handler
と書きましたが、 Listener
もほとんど同じ仕組みです。割愛します。
APIRecord
type APIRecordT> = {
[key: string]: ((...args: any[]) => PromiseT>) | APIRecordT>
}
API のレスポンスのレコードです。
不特定のキーを持つオブジェクトに、 Promise
である API のレスポンスあるいは、その中に更にオブジェクトとしてキーがあることも許容されています。
APISchema
type Status = {
code: number
message?: string
}
type APISchemaT = object | string | Buffer | null> = {
status: Status
data?: T
error?: string | Error | unknown
}
API のレスポンスのスキーマです。
ステータスコードを基として、レスポンスのデータやエラーの内容が定義されています。T = object | string | Buffer | null
となっているのは、画像等のレスポンスがあることを考慮しているため、各々の API の返り値に合わせて変更してください。
RecursiveAPI
type AsyncFunction = (...args: any[]) => Promiseany>
type RecursiveAPIT> = {
[K in keyof T]: T[K] extends (...args: any[]) => any
? (...args: ParametersT[K]>) => ReturnTypeT[K]>
: RecursiveAPIT[K]>
}
API のレスポンスを再帰的に取得するための型です。T[K]
が関数であれば、引数の型を取得して、戻り値の型を取得します。
そうでなければ、再帰的に RecursiveAPI
を取得します。
上記の型を使用して、下記のように実際に Handler を定義します。
コードは、私が別途開発しているものなので、内容は気にしなくても構いません。
const apiHandlers = {
project: {
create: async (project: Project): PromiseAPISchemaProject | null>> => {
const userDirPath = app.getPath('userData')
const projectsPath = path.join(userDirPath, 'project/projects')
try {
const projects = fs.readdirSync(projectsPath)
let projectId = uuid()
while (projects.includes(projectId)) {
projectId = uuid()
}
console.log('projectId', projectId)
project.id = projectId
const projectPath = path.join(projectsPath, projectId)
fs.mkdirSync(projectPath, { recursive: true })
const projectFilePath = path.join(projectPath, 'project.json')
fs.writeFileSync(projectFilePath, JSON.stringify(project, null, 2))
return logStatus(
{ code: 200, message: 'プロジェクトを作成しました' },
{ id: projectId, name: project.name, description: project.description, icon: project.icon, tags: project.tags, status: project.status } as Project,
)
} catch (err) {
return logStatus({ code: 500, message: 'プロジェクトの作成に失敗しました' }, null, err)
}
},
delete: async (projectId: string): PromiseAPISchemanull>> => {
const userDirPath = app.getPath('userData')
const projectsPath = path.join(userDirPath, 'project/projects')
try {
const projectPath = path.join(projectsPath, projectId)
fs.rmSync(projectPath, { recursive: true, force: true })
return logStatus({ code: 200, message: 'プロジェクトを削除しました' }, null)
} catch (err) {
return logStatus({ code: 404, message: 'プロジェクトが見つかりません' }, null, err)
}
},
}
} satisfies APIRecordAPISchema>
このように satisfies APIRecord
と型を定義させることで、
const apiHandlers: {
project: {
create: () => PromiseAPISchema{
project: Project;
} | null>>;
delete: (projectId: string) => PromiseAPISchemanull>>;
};
}
と、一つ一つの関数を型定義しなくとも、自動ですべての型を再帰的に定義してくれます。
ipcManager
こちらはコードの引用はしません。基本的に書いてあるとおりです。ipcManager
では、メインプロセスからフロントに対して信号やイベントを送りたいときに使用します。フロントでは、リスナーを起動しておけばその間はイベントを受け取ることができます。適切にリスナーを削除することで、双方通行の通信が実現されます。
メインプロセスでのリスナーの送信
const apiListeners = {
stream: {
onResponse: T extends unknown[]>(
callback: (args: T) => void
): PromiseAPISchema() => void>> => {
const safeCallback = (...args: unknown[]): void => {
callback(args as T)
}
ipcManager.on('stream:response', safeCallback)
return new Promise((resolve) => {
const removeListener = (): void => {
ipcManager.off('stream:response', safeCallback)
}
return resolve(
logStatus(
{ code: 200, message: 'stream:response リスナーを登録しました' },
removeListener
)
)
})
},
}
}
フロントでのリスナー起動の例
// onResponse は APISchema が適応されているので、 data の中にはリスナーを削除する関数が組み込まれています。
const stream = await window.api.listeners.stream.onResponse((chunks) => {
const chunk = chunks[0]
if (chunk) {
return chunk
} else {
return new Error('no chunks')
}
})
// リスナーの削除
stream.data()
返り値は logStatus
により管理されているため、 removeListener
は logStatus
の data
に格納されています。
いかがでしたでしょうか。応用の全く効かない記事が完成してしまいましたが、組み込みとして確立してしまえばかなり便利になります。
みなさんもぜひ contextBridge
に勝利してみてはいかがでしょうか。
今後はこれらを npm パッケージとしてまとめて、もっと簡潔に公開したいです。
また進捗がありましたら記事を投稿したいと思います。