📷

Electronでデスクトップのスクリーンショットを撮るアプリを作ってみた

2024/01/07に公開

はじめに

今まで使っていたスクリーンショットを撮るアプリがWindows11からうまく動かなくなり、他にいい感じのものもなかったので自作してみました。
フロントエンドについてはさっぱりなので間違いなどがあれば教えていただけると嬉しいです。

実行環境

Windows 11
Electron 28.1.0
Svelte 4.2.8
TypeScript 5.3.3

ソースコード

最初にソースコードを載せておきます。
プロジェクトの作成にはこちらのwebpackを利用しています。
コードはそれぞれの環境に合わせて適宜読み替えてください。

https://github.com/sprout2000/create-electron-webpack#readme

ソースコード
main.ts
const { BrowserWindow, app, ipcMain, desktopCapturer } = require(`electron`);
const path = require(`path`);
const fs = require('fs');

const createWindow = () => {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.resolve(__dirname, "preload.js"),
    },
  });

  mainWindow.loadFile("dist/index.html");
  
  ipcMain.on('take-screenshot', async () => {
    try {
      const sources = await desktopCapturer.getSources({ types: ['screen'] });
      if (sources.length === 0) {
          throw new Error('スクリーンソースが見つかりません。');
      }

      const source = sources[0];
      mainWindow.webContents.send('getImage', source.id);
    } catch (e) {
      console.error('スクリーンキャプチャに失敗しました:', e);
      return '';
    }
  });
};

app.whenReady().then(() => {
  createWindow();
});

app.once("window-all-closed", () => app.quit());

ipcMain.on('save-screenshot', async (event: any, dataURL: string) => {
  const buffer = Buffer.from(dataURL.split(',')[1], 'base64');
  try {
    fs.writeFileSync('output.png', buffer);
    console.log('書き込み成功');
  } catch (error) {
    console.error('ファイルの書き込みに失敗しました:', error);
  }
});
preload.ts
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('api', {
  takeScreenshot: () => ipcRenderer.send('take-screenshot'),
  getImage: (func: any) => {
    ipcRenderer.on('getImage', (event: any, sourceId: any) => func(sourceId))
  },
  saveScreenshot: (buffer: Buffer) => ipcRenderer.send('save-screenshot', buffer),
})
App.svelte
<script lang="ts">
  let savePath: string = '';

  function takeScreenshot() {
    (window as any).api.takeScreenshot();
  }

  async function getImage(sourceId: string) {
    try {
      const stream = await (navigator.mediaDevices as any).getUserMedia({
        audio: false,
        video: {
          mandatory: {
            chromeMediaSource: 'desktop',
            chromeMediaSourceId: sourceId,
          },
        },
      });

      const video = document.createElement('video');
      video.srcObject = stream;
      video.onloadedmetadata = () => {
        video.play();
        const canvas = document.createElement('canvas');
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        const ctx = canvas.getContext('2d');
        ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);

        const dataURL = canvas.toDataURL('image/png');

        const tracks = stream.getTracks();
        tracks.forEach((track: { stop: () => any; }) => track.stop());

        (window as any).api.saveScreenshot(dataURL);
      };
    } catch (error) {
      console.error('エラーが発生しました:', error);
    }
  }

  (window as any).api.getImage((sourceId: string) => getImage(sourceId));
</script>

<div class="container">
  <button on:click={takeScreenshot}>スクリーンショット</button>
</div>

<style>
  button {
    background-color: #4CAF50;
    color: white;
    padding: 10px 15px;
    border: none;
    border-radius: 4px;
    cursor: pointer;
  }

  button:hover {
    background-color: #45a049;
  }
</style>

利用可能なスクリーンを取得する

まずはメインプロセスにレンダラープロセスからのメッセージを受信するためにリスナーを実装していきます。

メインプロセス

main.ts
 const { BrowserWindow, app, ipcMain, desktopCapturer } = require(`electron`);
 const path = require(`path`);

 const createWindow = () => {
   const mainWindow = new BrowserWindow({
     webPreferences: {
       preload: path.resolve(__dirname, "preload.js"),
     },
   });

   mainWindow.loadFile("dist/index.html");

+  ipcMain.on('take-screenshot', async () => {
+    try {
+        const sources = await desktopCapturer.getSources({ types: ['screen'] });
+        if (sources.length === 0) {
+            throw new Error('スクリーンソースが見つかりません。');
+        }
+
+        const source = sources[0];
+    } catch (e) {
+        console.error('スクリーンキャプチャに失敗しました:', e);
+        return '';
+    }
+  });
};

 app.whenReady().then(() => {
   createWindow();
 });
 
 app.once("window-all-closed", () => app.quit());


ここではdesktopCapturer.getSourcesで利用可能なスクリーン(ディスプレイ)一覧を取得するリスナーを登録しています。

ちなみにdesktopCapturer.getSourcesはレンダラープロセスでは動作しないので注意してください。

プリロード

次にレンダラープロセスからメインプロセスのリスナーにメッセージを送信できるようにするために、プリロード経由でAPIを公開します。
第一引数にapiにアクセスするためのキー名、第二引数に公開したいAPIオブジェクトを設定していきます。

preload.ts
 console.log("preloaded!");

+ const { contextBridge, ipcRenderer } = require('electron');

+ contextBridge.exposeInMainWorld('api', {
+   takeScreenshot: () => ipcRenderer.send('take-screenshot'),
+ })

レンダラープロセス

適当にボタンを配置し、そのボタンのonClickに先程設定したcontextBridgeで設定したtakeScreenshotを呼び出します。
apiの呼び出しはwindowオブジェクトを経由して行います。

App.svelte
 <script lang="ts">
+   let savePath: string = '';

+   function takeScreenshot() {
+     (window as any).api.takeScreenshot();
+   }
 </script>

 <div class="container">
+   <button on:click={takeScreenshot}>スクリーンショット</button>
 </div>

ここまでの実装で利用可能なスクリーンを取得し、その情報をレンダラープロセスに送信するところまでできました。

スクリーンショットを取得する

次は取得したスクリーン情報を元に、レンダラープロセスでスクリーンショットを撮る機能を実装していきます。

プリロード

メインプロセスからレンダラープロセスへ値を渡すために、プリロードにレンダラープロセスからAPIを公開します。

preload.ts
contextBridge.exposeInMainWorld('api', {
  takeScreenshot: () => ipcRenderer.send('take-screenshot'),
+ getImage: (func: any) => {
+   ipcRenderer.on('getImage', (event: any, sourceId: any) => func(sourceId))
+ },
})

レンダラープロセス

次にレンダラープロセスのスクリプトにスクリーンショットを撮る関数を用意し、先程定義したgetImageにコールバックとして渡します。

App.svelte
 <script lang="ts">
  let savePath: string = '';

  function takeScreenshot() {
    (window as any).api.takeScreenshot();
  }

+  async function getImage(sourceId: string) {
+    try {
+      const stream = await (navigator.mediaDevices as any).getUserMedia({
+        audio: false,
+        video: {
+          mandatory: {
+            chromeMediaSource: 'desktop',
+            chromeMediaSourceId: sourceId,
+          },
+        },
+      });

+      const video = document.createElement('video');
+      video.srcObject = stream;
+      video.onloadedmetadata = () => {
+        video.play();
+        const canvas = document.createElement('canvas');
+        canvas.width = video.videoWidth;
+        canvas.height = video.videoHeight;
+        const ctx = canvas.getContext('2d');
+        ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);

+        const dataURL = canvas.toDataURL('image/png');

+        const tracks = stream.getTracks();
+        tracks.forEach((track: { stop: () => any; }) => track.stop());
+      };
+    } catch (error) {
+      console.error('エラーが発生しました:', error);
+    }
+  }
 </script>

 <div class="container">
  <button on:click={takeScreenshot}>スクリーンショット</button>
 </div>

メインプロセス

WebContentsを利用してスクリーン情報をレンダラープロセスに送信します。
今回はメインディスプレイのスクリーンショットを撮るためにsources[0]のidを送信しています。

main.ts
  ipcMain.on('take-screenshot', async () => {
    try {
      const sources = await desktopCapturer.getSources({ types: ['screen'] });
      if (sources.length === 0) {
          throw new Error('スクリーンソースが見つかりません。');
      }

      const source = sources[0];
+     mainWindow.webContents.send('getImage', source.id);
    } catch (e) {
      console.error('スクリーンキャプチャに失敗しました:', e);
      return '';
    }
  });

スクリーンショットの保存

最後に撮影したスクリーンを保存する処理を実装します。
保存処理はメインプロセスで行うため、利用可能なスクリーンを取得した時と同様の実装をします。

メインプロセス

fsライブラリを利用して画像の保存処理を実装します。
ここではレンダラープロセスから受け取ったpng形式のDataURLから画像を抽出し、Buffer.fromでバイナリデータに変換してルートディレクトリに保存しています。

main.ts
const { app, BrowserWindow, globalShortcut, desktopCapturer, ipcMain } = require('electron');
const path = require('path');
+ const fs = require('fs');

const createWindow = () => {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.resolve(__dirname, 'preload.js'),
    },
  });

  mainWindow.loadFile("dist/index.html");

  ipcMain.on('take-screenshot', async () => {
    try {
      const sources = await desktopCapturer.getSources({ types: ['screen'] });
      if (sources.length === 0) {
          throw new Error('スクリーンソースが見つかりません。');
      }

      const source = sources[0];
      mainWindow.webContents.send('getImage', source.id);
    } catch (e) {
      console.error('スクリーンキャプチャに失敗しました:', e);
      return '';
    }
  });

};


app.whenReady().then(() => {
  createWindow()
})

app.on('window-all-closed', function () {
  if (process.platform !== 'darwin') app.quit()
})

+ipcMain.on('save-screenshot', async (event: any, dataURL: string) => {
+  const buffer = Buffer.from(dataURL.split(',')[1], 'base64');
+ try {
+    fs.writeFileSync('output.png', buffer);
+    console.log('書き込み成功');
+  } catch (error) {
+    console.error('ファイルの書き込みに失敗しました:', error);
+ }
+});

プリロード

登録したリスナーをレンダラープロセスに公開します。

preload.ts
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('api', {
  savePath: (path: any) => ipcRenderer.send('save-path', path),

  

  takeScreenshot: () => ipcRenderer.send('take-screenshot'),

  getImage: (path: any, func: any) => {
    ipcRenderer.on('getImage', (event: any, sourceId: any) => func(sourceId))
  },

+  saveScreenshot: (buffer: Buffer) => ipcRenderer.send('save-screenshot', buffer),
})

レンダラープロセス

プリロードを介してメインプロセスに取得したdataURLを渡します。

App.svelte
 <script lang="ts">
  let savePath: string = '';

  function takeScreenshot() {
    (window as any).api.takeScreenshot();
  }

  (window as any).api.getImage('', async (sourceId: string) => {
    try {
      const stream = await (navigator.mediaDevices as any).getUserMedia({
        audio: false,
        video: {
          mandatory: {
            chromeMediaSource: 'desktop',
            chromeMediaSourceId: sourceId,
          },
        },
      });

      const video = document.createElement('video');
      video.srcObject = stream;
      video.onloadedmetadata = () => {
        video.play();
        const canvas = document.createElement('canvas');
        canvas.width = video.videoWidth;
        canvas.height = video.videoHeight;
        const ctx = canvas.getContext('2d');
        ctx?.drawImage(video, 0, 0, canvas.width, canvas.height);

        const dataURL = canvas.toDataURL('image/png');

        const tracks = stream.getTracks();
        tracks.forEach((track: { stop: () => any; }) => track.stop());

+       (window as any).api.saveScreenshot(dataURL);
      };
    } catch (error) {
      console.error('エラーが発生しました:', error);
    }
  });
 </script>

 <div class="container">
  <button on:click={takeScreenshot}>スクリーンショット</button>
 </div>

スクリーンショットを撮る

npm run devで起動して「スクリーンショット」ボタンを押すとルートディレクトリにoutput.pngが生成されます。
npm run devはwebpackのデフォルトで用意されているscriptです

余談

マルチモニターで特定のディスプレイのスクリーンショットを撮りたい場合、sourcedisplay_idscreen.getAllDisplaysを比較するといい感じに識別できると思います。

main.ts
const createWindow = () => {
  const mainWindow = new BrowserWindow({
    webPreferences: {
      preload: path.resolve(__dirname, 'preload.js'),
    },
  });

  mainWindow.loadFile("dist/index.html");

  ipcMain.on('take-screenshot', async () => {
    try {
      const sources = await desktopCapturer.getSources({ types: ['screen'] });
      const source = sources[0];
      console.log('getSources:', sources[0].display_id); // ここを比較
      mainWindow.webContents.send('getImage', source.id);
};

app.whenReady().then(() => {
  createWindow()

  const { screen } = require('electron');
  
  const displays = screen.getAllDisplays();
  displays.forEach((display: { id: any; label: any; size: any; workAreaSize: any; }) => {
    console.log('ID:', display.id); // ここを比較
    console.log('Label:', display.label);
    console.log('Size:', display.size);
    console.log('Resolution:', display.workAreaSize);
  });
})

参考

https://github.com/sprout2000/create-electron-webpack#readme

https://www.electronjs.org/docs/latest/api/desktop-capturer

https://www.electronjs.org/ja/docs/latest/api/ipc-main

https://hi1280.hatenablog.com/entry/2018/01/05/215506

Discussion