Open9

typescriptでもelectron IpcMainと仲良くしたい

みつきみつき

nativeでParametersという関数型のパラメーター抽出してくれるジェネリクスはあるけど、electronではelectronのnamespaceの中ですでに独自のParameters Interfaceがあったため、nativeのParametersこれが使えなくなったので、別の名前を付けて自作しましょう。

ま、自作と言ってもnativeのParametersをコピペしただけですけど

export type ArgumentTypes<T extends (...args: any[]) => any> = T extends (...args: infer P) => any ? P : never;

これがないと始まらない

みつきみつき

まず使う時の書き方を考えよう、1つのソースでrendererとmain両方で使いたいなら、まずmain側とrenderer側のtypeが違う点を注意すべきかと思います。

なので以下のような使い方を考えてみよう、

export interface IpcInvokeEventMap {
  "any-event-name": (data: YourDataType) => YourReturnType;
}

export interface IpcMainEventMap {
  "any-event-name": (data: YourDataType) => void; // mainはelectronの仕様でvoidを返さないといけないのでvoidで書く
}

このような書き方で直接ipcなどで使うと、1つ問題点がある、electronでのinvokeでは、rendererがinvokeして、そしてmainがこれを受け取って何かを処理したら値を返す仕組みなので、main側は普通の値をreturnしてるが、renderer側のreturnはpromiseになる

なので1つの普通returnでmainとrenderer両方をコントロールしたいであれば、これを変換してくれるジェネリクスが必要

export type RendererInvokeType<E extends keyof IpcInvokeEventMap> = (
  ...args: ArgumentTypes<IpcInvokeEventMap[E]>
) => Promise<ReturnType<IpcInvokeEventMap[E]>>;
みつきみつき

そしてようやく実際に本番のtype定義を書ける。
Ipcはmainとinvokeに分かれているので、書く時もそれぞれ書く必要がある。

// globalの中にあるelectronを拡張して書く
declare global{
  namespace Electron {
    interface IpcRenderer {
      invoke<E extends keyof IpcInvokeEventMap>(
        channel: E,
        args: ArgumentTypes<IpcInvokeEventMap[E]>,
      ): Promise<ReturnType<IpcInvokeEventMap[E]>>;
      send<E extends keyof IpcInvokeEventMap>(
        channel: E,
        args: ArgumentTypes<IpcInvokeEventMap[E]>,
      ): void;
    }
    interface IpcMain {
      handle<E extends keyof IpcInvokeEventMap>(
        channel: E,
        listener: (
          event: IpcMainInvokeEvent,
          args: ArgumentTypes<IpcInvokeEventMap[E]>,
        ) => ReturnType<IpcInvokeEventMap[E]>,
      ): void;
      handleOnce<E extends keyof IpcInvokeEventMap>(
        channel: E,
        listener: IpcInvokeEventMap[E],
      ): void;

      on<E extends keyof IpcMainEventMap>(
        channel: E,
        listener: (
          event: IpcMainEvent,
          args: ArgumentTypes<IpcMainEventMap[E]>,
        ) => void,
      ): this;
      once<E extends keyof IpcMainEventMap>(
        channel: E,
        listener: (
          event: IpcMainEvent,
          args: ArgumentTypes<IpcMainEventMap[E]>,
        ) => void,
      ): this;
    }
  }
}

renderer側はinvokeとsendの2つしか書いてないが、他も大体同じなので、必要になったらまた書き足そうかなと思う

みつきみつき

そして最後はpreloadで公開する時のtypesを書いていく

ここら辺は公式でも取り上げているので参考しながら書いていく

https://www.electronjs.org/ja/docs/latest/tutorial/context-isolation

まずはtypesの定義

export interface CustomeElectronAPI {
  anyMainActionName: IpcMainEventMap["any-event-name"]
  anyInvokeActionName: RendererInvokeType<"any-event-name">;
}

declare global{
   interface Window {
    electronAPI: CostomeElectronAPI;
  }
}

そしてpreload.tsでは以下のように書ける

preload.ts
const api: CustomeElectronAPI = {
  anyMainActionName: ipcRenderer.send("any-event-name"),
  anyInvokeActionName: ipcRenderer.invoke("any-event-name"),
};

contextBridge.exposeInMainWorld("electronAPI", api);

これで準備万端。
CustmoeElectronAPIと違うものを書こうとするとちゃんとエラーが出て怒ってくれます。

みつきみつき

実際にrendererの中に使う場合はこのように使える

const eleAPI = window.electronAPI;
// ドット記法を使えばglobalの中のelectronAPI経由でCustomElectronAPIの中の定義が使える
eleAPI.anyMainActionName();

コード補完もできてますね、よかったです

みつきみつき

しかしIpcMainで本家のtype定義だと => (Promise<void>) | (any) になっているので、なのでreturnが違ってもエラーになってくれないのよね...ここだけ悩む

どなたが良い解決法があったら是非ご教示していただければ幸いです。

みつきみつき

preloadで公開する時はnested構造できるので、公式の例を交えながら書くと以下のように書いた方がいいかも。

例えば押したらwindowを閉じるボタンを作るのと、どのモードでappを起動しているかの2つの機能を作ろうとする

閉じるボタンは何もreturnデータをもらう必要ないので、mainでかく、モードをgetする機能はデーターをもらう必要があるのでinvokeで書く。

まずはtypes定義

export interface IpcInvokeEventMap {
  "get:appMode": () => "production" | "development";
}

export interface IpcMainEventMap {
  "close-window": () => void;
}

export interface CustomeElectronAPI {
  const: {
    nodeVersion: string;
    chromeVersion: string;
    electronVersion: string;
  };
  send: {
    close: IpcMainEventMap["close-window"];
  };
  invoke: {
    getAppMode: RendererInvokeType<"get:appMode">;
  };
}

そしてprelodでの記述

preload.ts
const api: CustomeElectronAPI = {
  const: {
    nodeVersion: process.versions.node,
    chromeVersion: process.versions.chrome,
    electronVersion: process.versions.electron,
  },
  send: {
    close: () => ipcRenderer.send("close-window"),
  },
  invoke: {
    getAppMode: () => ipcRenderer.invoke("get:appMode"),
  },
};

contextBridge.exposeInMainWorld("electronAPI", api);

mainでの記述(まず環境変数にAPP_MODEという変数をexportしとく必要がある)

main.ts
ipcMain.handle("get:appMode", () => {
  const mode = process.env.APP_MODE;
  if (!mode) {
    throw new Error(
      "can not find APP_MODE in your enviroment variables",
    );
  }
  return mode;
});
ipcMain.on("close-window", () => {
  mainWindow.close();
});

実際にrendererで使ってみる

renderer.ts
const eleAPI = window.electronAPI;
const mode = await eleAPI.invoke.getAppMode();
const { nodeVersion, electronVersion, chromeVersion} = eleAPI.const

eleAPI.send.close()

コード補完の恩恵もちゃんと受けられてますね

みつきみつき

ちょっとだけ上で述べていたが

IpcMainで本家のtype定義だと => (Promise<void>) | (any) になっているので、なのでreturnが違ってもエラーになってくれないのよね...ここだけ悩む

これを解決したらこのスクラップをクローズしようと思うが、今はまだまだ何かいい案を思いつかない

みつきみつき

実際に使ってみたらAPI周りを独立してあげる利点はあまりなかった。
メリットは実際に送る際のchannelと違う名前が使えるぐらいで、でも別に同じ名前でいいじゃん
わざわざ別の名前使うなら二回書かないといけないし、めんどい!
なので下のコードになった

export type CustomeElectronAPI = {
  const: {
    nodeVersion: string;
    chromeVersion: string;
    electronVersion: string;
  };
  send: {
    [key in keyof IpcMainEventMap]: IpcMainEventMap[keyof IpcMainEventMap];
  };
  invoke: {
    [key in keyof IpcInvokeEventMap]: RendererInvokeType<
      keyof IpcInvokeEventMap
    >;
  };
};

この書き方で上と同じようにget:fooもしくはclose-windowのような形で書きたければドット記法が使えなくなるので、大人しくbaz["get:foo"]などの形でかく必要がある。
めんどいのでキャメルケースに全部変えました。

export type IpcInvokeEventMap = {
  getAppMode: () => "production" | "development";
};

export type IpcMainEventMap = {
  closeWindow: () => void;
};